Persisting layouts
Save and restore a layout with the persist prop (and the lower-level hooks), storage adapters, validation, and SSR.
A dashfoo layout is a plain serializable model. The user rearranges tabs,
drags a panel to a new region, drags a splitter, and each of those changes
produces a new Dashfoo value. Persisting the layout means saving that
value somewhere on every change and loading it back on the next visit.
For most apps, use the persist prop: pass a localStorage key and
DashfooLayout wires up the whole loop. It loads once, validates the stored
payload, debounce-saves every change, and flushes on page hide and unmount. A ref exposes
resetLayout() to clear the saved copy and return to the default.
import type { DashfooHandle } from "@dashfoo/react";
import { DashfooLayout } from "@dashfoo/react";
import { useRef } from "react";
const layout = useRef<DashfooHandle>(null);
<DashfooLayout
defaultModel={defaultModel}
factory={renderPanel}
persist="dashfoo:my-app"
ref={layout}
/>;
// elsewhere, e.g. a "Reset layout" button:
layout.current?.resetLayout();persist accepts a bare key (localStorage) or a full target with a custom store
and debounce:
persist={{ key: "dashfoo:my-app", storage: sessionStorage, debounceMs: 500 }}It is part of the uncontrolled half of the props union (you pass defaultModel), so passing it with model does not compile. In controlled mode,
where you pass model + onModelChange and already own the state, skip persist
and save the model yourself in onModelChange.
Everything below maps to packages/react/src/hooks/persistence.ts
and the validation pipeline in packages/core/src/model/serialize.ts.
The persist prop is built on the shared usePersistence primitive in that file
(also exported, for hosts that drive the store directly). The storage adapter,
validation, and debounce below apply to it directly.
Storage adapter
The storage option is the only injection point, and its shape is the three
localStorage methods persistence calls:
type StorageAdapter = {
getItem: (key: string) => string | null;
removeItem: (key: string) => void;
setItem: (key: string, value: string) => void;
};Anything matching that shape works: localStorage, sessionStorage, an
in-memory map, or a custom store that batches writes to a server. The adapter
is synchronous: backend synchronization belongs in controlled mode, not in an
adapter that returns promises. Two adapters ship from @dashfoo/react.
localStorageAdapter (default)
The default, and SSR-safe by construction. Every method guards
typeof window === "undefined" and wraps the call in try/catch:
- On the server,
getItemreturnsnull(so you loaddefaultModel) and writes are no-ops. - In the browser, a read that throws (private mode, blocked storage)
warns and returns
nullrather than crashing. - A write that throws (quota exceeded, private mode) is not swallowed
silently: it logs
console.warn("[dashfoo] failed to persist layout", …). The layout keeps working; only persistence is degraded.
memoryStorageAdapter for SSR and tests
memoryStorageAdapter() is a factory that returns a fresh Map-backed
adapter. Pass it to the persist prop's full form when you want isolation
rather than the real browser store:
import { memoryStorageAdapter } from "@dashfoo/react";
// In a test, or per-request on the server, a clean store every time.
const storage = useMemo(() => memoryStorageAdapter(), []);
<DashfooLayout defaultModel={model} persist={{ key: "dashfoo:my-app", storage }} />;Keep the adapter instance stable across renders (a module constant or a
useMemo/useState initializer), or each render hands persistence a brand-new
empty store and nothing persists.
Validation on load
Persistence never trusts what comes out of storage. Loading runs the raw string
through fromJSON from @dashfoo/core, the untrusted-input pipeline in
serialize.ts. Condensed:
const fromJSON = (json: string): Dashfoo => parseModel(JSON.parse(json));
const parseModel = (value: unknown): Dashfoo => normalize(dashfooSchema.parse(value));Two things happen in order:
dashfooSchema.parsevalidates against the zod schema. A hand-edited, truncated, or corrupt payload fails here and throws. The schema pins the payload'sversionto1, so a payload in any other format is rejected rather than loaded lossily.normalizereturns a canonical model (the same normalization the engine applies internally), so a valid-but-noncanonical payload loads as the engine would store it.
parseModel also checks the loaded model for duplicate node ids and warns
(without throwing) when it finds any, since duplicates corrupt React keys.
A fromJSON that throws is caught and the model falls back to defaultModel. A
corrupt entry is also pruned: the mount effect reads the stored value once, and if it
fails to parse, calls storage.removeItem(key) so the bad payload does not sit
there failing on every load. A user with a broken saved layout gets the default
and a clean slate, not a crash.
Evolving persisted layouts
The pinned version has a consequence worth planning for: there is no
migration machinery. When a future release bumps the format, every stored
payload in the old format fails validation, and fromJSON / parseModel
throw on the mismatch. Invalidating old payloads is the design.
The recommended pattern is the one persistence already implements: wrap the
load in try/catch and fall back to defaultModel. The persist prop does
this automatically (a payload that fails to parse loads the default and the
stored entry is removed), so a format bump costs existing users their saved
arrangement but never crashes.
App-level migration means owning that load yourself: read the raw JSON,
transform it as plain data (rename a component key that changed between
releases, drop a tab you removed), then validate the result with
parseModel, which takes unknown:
import type { Dashfoo } from "@dashfoo/core";
import { parseModel } from "@dashfoo/core";
// Plain JSON surgery on the stored payload, before validation.
const migrate = (value: unknown): unknown => value;
const loadLayout = (key: string, fallback: Dashfoo): Dashfoo => {
const raw = localStorage.getItem(key);
if (raw === null) {
return fallback;
}
try {
return parseModel(migrate(JSON.parse(raw)));
} catch {
return fallback;
}
};Pass the result as defaultModel. The persist prop then saves changes under
the same key as usual. The same shape without the migrate call is the plain
validated load, for hosts that read storage outside the persist prop.
Debounced save and flush
Persistence does not write on every change. Each change serializes the model with
toJSON, stashes it as a pending write, and schedules a flush debounceMs
(default 300 ms) later. A burst of changes (dragging a splitter produces many)
collapses into one write.
The pending write carries the key and storage it was produced for. If the
config changes while a save is queued, the flush still writes to the original
target, so a config switch can never write the old model into the new one.
A change still in the debounce window when the page goes away can be lost: a
reload or tab close never unmounts React, so the debounce timer dies with the
page. Persistence flushes the pending write on pagehide and when the page
becomes hidden (visibilitychange), as well as in a cleanup effect on unmount,
so reloading right after a drag still saves the last change.
resetLayout() (on the imperative handle) is the
inverse: it cancels any pending timer, drops the pending write, removes the stored
key, and resets the live model to the original defaultModel.
Worked example: the demo overview page
The demo's overview route
(apps/demo-vite/src/pages/overview.tsx)
is the whole pattern in one component:
import type { DashfooHandle } from "@dashfoo/react";
import { DashfooLayout } from "@dashfoo/react";
import { useMemo, useRef } from "react";
import { OverviewPanel } from "../components/overview-panel";
import { Button, DemoStage } from "../components/demo-stage";
import { overviewModel } from "../models";
const OverviewPage = () => {
const defaultModel = useMemo(() => overviewModel(), []);
const layout = useRef<DashfooHandle>(null);
return (
<DemoStage
actions={<Button onClick={() => layout.current?.resetLayout()}>Clear saved layout</Button>}
description="Saved to localStorage on every change (validated on load). Rearrange it, then reload; your arrangement survives."
title="Overview"
>
<DashfooLayout
defaultModel={defaultModel}
factory={OverviewPanel}
persist="dashfoo:demo:overview"
ref={layout}
/>
</DemoStage>
);
};Points worth copying:
defaultModelis wrapped inuseMemoso the model identity is stable across renders (thepersistprop loads from storage once on mount).persist="…"passes a bare key, so it useslocalStorageAdapter. Rearrange the layout, reload, and the arrangement is restored fromlocalStorage.- "Clear saved layout" calls
ref.current.resetLayout(), which clears the saved copy and resets the live model todefaultModelwithout a remount.
SSR safety
The default adapter is already SSR-safe, so the hook does not crash during a
server render: getItem returns null, you load defaultModel, and the
server and the first client render agree. The browser then loads the saved
model after mount, applies it without an undo entry, and does not save the seed
over the restored data. Custom adapter read, write, and remove failures also
warn without taking down the layout.
If you want each request to start from an isolated store (no shared module
state between requests), pass a per-request memoryStorageAdapter() and
hydrate it from your own request-scoped source. For the common case,
client-side persistence to localStorage, the default needs no extra
wiring.
Using the hook directly
usePersistence returns { initialModel, save, clear }. initialModel starts
as the seed and changes after mount when valid storage is restored. A host
that creates a store once must apply that restored snapshot with setModel
in an effect; passing it only as the store's defaultModel will not update an
already mounted store. Prefer the persist prop, which handles this sequence.
Changing a mounted persistence key warns; remount with a React key to load
another arrangement.
See also
packages/react/src/hooks/persistence.tsfor the hook, both adapters, and theStorageAdaptertype.packages/core/src/model/serialize.tsforfromJSON/toJSONandparseModel.apps/demo-vite/src/pages/overview.tsxfor the end-to-end worked example.