Decision. Two problem classes, two answers. Splitters (just pane sizes): use react-resizable-panels
autoSaveId— zero-code localStorage, swap thestorageprop for a backend adapter when you need per-user sync [1][2]. Docking (tabs, groups, arrangement): DockviewtoJSON/fromJSON, rc-docksaveLayout/loadLayout, or FlexLayoutModel.toJson/fromJson[3][8][9]. For docking, always wrapfromJSONin a version check + a prune-unknown-panels pass + a try/catch default fallback — these libraries reference panels by string name and throw (or wedge) when a saved layout names a panel you’ve since deleted [5][6].
The two problem shapes
Persisting a splitter means persisting an array of numbers (pane sizes). Persisting a docking layout means persisting a tree: which panels exist, how they’re grouped into tabsets, their split geometry, and which is active. The docking case is where all the hard problems (registry drift, versioning) live.
JSON model shape per library
| Library | ⭐ Stars | Serialize / restore | What the payload contains |
|---|---|---|---|
| react-resizable-panels | ⭐ 5.3k | autoSaveId (auto) or onLayout + storage |
Array of size percentages, keyed by sorted panel IDs [1][2] |
| Allotment | ⭐ 1.3k | onChange(sizes) → you store; defaultSizes / ref.resize() to restore |
Array of numbers (pixel sizes) [10] |
| Dockview | ⭐ 3.3k | api.toJSON() / api.fromJSON() |
SerializedDockview: { grid: { root, width, height, orientation }, panels, activeGroup } [3][11] |
| rc-dock | ⭐ 812 | saveLayout() / loadLayout() |
Box/panel/tab tree; tabs stored as IDs only, content rehydrated via loadTab [8] |
| FlexLayout | ⭐ 1.3k | model.toJson() / Model.fromJson() |
{ global, layout: row/tabset/tab, borders, subLayouts }; tab weights are proportional [9] |
The unifying trait for all three docking libraries: a tab/panel node stores a component name (a string), not the component. Restoration is a lookup — the name is resolved against a registry (components map in Dockview, loadTab in rc-dock, the factory function in FlexLayout) to produce the actual React element [9][12][8]. That indirection is exactly what breaks across releases (see Versioning below).
Splitters: the size array
react-resizable-panels serializes to localStorage under the key react-resizable-panels:<autoSaveId> with no code beyond the prop [13]:
<PanelGroup autoSaveId="ide-layout" direction="horizontal">
<Panel id="sources" order={1} defaultSize={25}><Sources /></Panel>
<PanelResizeHandle />
<Panel id="viewer" order={2}><Viewer /></Panel>
</PanelGroup>
When panels are conditionally rendered, give each Panel a stable id and order so the persisted array maps back to the right pane [2]. Allotment has no built-in persistence — you wire onChange (debounced) to your store and restore with defaultSizes or an imperative ref.current.resize([...]) [10].
Docking: capture on change
All three docking libraries expose a change event so you can autosave without a save button — Dockview onDidLayoutChange, FlexLayout onModelChange, rc-dock onLayoutChange [3][9]:
api.onDidLayoutChange(() => {
localStorage.setItem('dock', JSON.stringify(api.toJSON()));
});
Where to persist
| Target | Use when | Trade-offs |
|---|---|---|
| localStorage | Single-device convenience, no auth | Per-browser, lost on cache clear; SSR hydration flash [1] |
| Cookie | You SSR and want zero flash | Server reads the cookie and sets defaultSize/defaultLayout before paint; react-resizable-panels ships a cookie-storage pattern for this [13][1] |
| Backend / user profile | Cross-device sync, per-user layouts, teams | Needs auth + an API round-trip; debounce writes; handle offline |
The SSR flash is real: the server renders the default layout, then client JS swaps in the localStorage layout on mount. A cookie (readable server-side) removes the flash because the server already knows the persisted sizes [1].
Per-user server-side persistence
The clean seam is a storage adapter. react-resizable-panels’ storage prop takes any object matching:
interface PanelGroupStorage {
getItem: (name: string) => string;
setItem: (name: string, value: string) => void;
}
Both methods are synchronous [2], so a backend adapter keeps an in-memory cache that a debounced PATCH /me/layout flushes:
const backendStorage: PanelGroupStorage = {
getItem: (k) => cache[k] ?? '',
setItem: (k, v) => { cache[k] = v; queueFlush(k, v); }, // debounced PUT
};
// <PanelGroup autoSaveId="ide" storage={backendStorage}>
For docking libraries there’s no storage prop — you own the plumbing: hydrate the layout from GET /me/layout before mounting the dock (render a default until it resolves), and push toJSON() output on the change event through a debounced writer. Store one row per (userId, layoutKey) with the serialized JSON in a jsonb/nvarchar(max) column plus a schema_version integer (below).
The versioning / migration problem
This is the failure mode that bites in production. A saved layout is a snapshot of the panel set as it was when saved. Ship a release that renames or removes a panel, and a returning user’s stored layout now references a panel name your registry no longer knows.
- Dockview throws on an unknown component name, and historically wedged so badly that even
api.clear()failed — you had to manually wipe localStorage. The maintainer calls invalid component names “the single most likely form of layout corruption” [6]. Modern Dockview resets gracefully on a badfromJSON, but your app still has to catch the error and supply a default [5]. - react-resizable-panels: changing the number of panels makes the stored size array stale; the fix is to bump
autoSaveId(e.g."ide-v2") or clear the old key on startup [13]. - FlexLayout / rc-dock: an orphaned tab node routes to a
factory/loadTabcall with a name that has no mapping — a blank or broken tab unless you handle the miss [9][8].
None of these libraries validate a saved layout against your current panel registry for you. You must.
Concrete patterns (cheapest → most robust)
- Bump the key / version stamp. Wrap every payload:
{ schemaVersion: 3, layout }. On load, ifschemaVersion !== CURRENT, discard and use the default. Coarse — throws away all customization on any change — but a two-line safety net [13]. - Migrate. Keep ordered migration functions
v1→v2→v3that rewrite the JSON (rename a component, drop a node) before it reachesfromJSON. Preserves user arrangement across renames; the standard redux-persist-style approach. - Prune-unknown (the robust default). Before restoring, walk the serialized tree and delete any panel/tab whose component name isn’t in your current registry; collapse now-empty tabsets. Then feed the sanitized tree to
fromJSON. This survives unanticipated drift, not just versions you migrated:
function sanitize(saved, knownNames: Set<string>) {
const panels = Object.fromEntries(
Object.entries(saved.panels).filter(([, p]) => knownNames.has(p.contentComponent))
);
return prunePanelRefs({ ...saved, panels }, new Set(Object.keys(panels)));
}
- try/catch with default fallback (always, as the last line):
try {
api.fromJSON(sanitize(migrate(saved), knownNames));
} catch {
localStorage.removeItem('dock'); // or clear the backend row
api.fromJSON(DEFAULT_LAYOUT);
}
Recommended approach
- Splitter-only UIs: react-resizable-panels ⭐ 5.3k with
autoSaveId. Stableid+orderon everyPanel. Go to a cookie-backedstorageif you SSR, a backend adapter if you need per-user sync [1][2]. - Docking UIs: pick Dockview ⭐ 3.3k for the richest API and framework wrappers [3][7]. Persist
{ schemaVersion, payload }per user server-side. On restore run version-gate → migrate → prune-unknown →fromJSONin try/catch → default fallback, in that order. The prune pass plus the try/catch are non-negotiable: they are what stop a single deleted panel from bricking a returning user’s whole workspace [6][5]. - Don’t store the size array or layout tree without a version stamp. The day you restructure panels, unstamped payloads are landmines.