Sitelet https://docs.dashfoo.com/persistence
dashfoo

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, getItem returns null (so you load defaultModel) and writes are no-ops.
  • In the browser, a read that throws (private mode, blocked storage) warns and returns null rather 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:

  1. dashfooSchema.parse validates against the zod schema. A hand-edited, truncated, or corrupt payload fails here and throws. The schema pins the payload's version to 1, so a payload in any other format is rejected rather than loaded lossily.
  2. normalize returns 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:

  • defaultModel is wrapped in useMemo so the model identity is stable across renders (the persist prop loads from storage once on mount).
  • persist="…" passes a bare key, so it uses localStorageAdapter. Rearrange the layout, reload, and the arrangement is restored from localStorage.
  • "Clear saved layout" calls ref.current.resetLayout(), which clears the saved copy and resets the live model to defaultModel without 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