A trader who spends eight hours a day in front of a screen does not arrange their panels for fun. They drag the order book to the left, widen the chart, pin four symbols to a watchlist, and switch the whole thing to dark. Then they hit refresh - or their browser crashes, or the dev server hot-reloads - and every one of those decisions is supposed to still be there. If it is not, the tool feels disposable. Workspace persistence is one of those features nobody praises when it works and everybody curses when it does not.
The mechanism is humble: localStorage, a synchronous key/value store with roughly 5MB per origin. The hard parts are not the API. They are deciding what deserves to survive a reload, keeping the schema versioned so a future refactor does not crash on stale data, and not hammering the store (or triggering a re-render storm) every time someone nudges a splitter. This post walks through how the Hedge UI demo - a React 19.2.1 / TypeScript 5.8.3 / Vite 7.2.6 single-page app - handles each of those, and where it deliberately stops short.
What is worth persisting
Not all state is equal. The useful test is: if this value vanished on reload, would the user have to redo deliberate work, or would it just be recomputed anyway? Persist the former, never the latter.
| State | Persist? | Why |
|---|---|---|
| Panel layout (the FlexLayout tree) | Yes | Manual arrangement; expensive to rebuild by hand |
| Selected / active layout id | Yes | A deliberate choice between saved workspaces |
| Per-panel settings (chart interval, table columns) | Yes | Configured once, expected to stick |
| Selected product / trading pair | Yes | Where the user left off |
| Favourites / watchlist | Yes (candidate) | Hand-curated list |
| Theme + density | Yes (candidate) | A preference, set rarely, changed rarely |
| Live order book, ticker, trades | No | Stale within milliseconds; refetch on mount |
| Hover, focus, open dropdowns | No | Ephemeral; persisting them feels haunted |
| Auth tokens | No | Belongs in httpOnly cookies, not JS-readable storage |
The line that matters most is the last block. Persisting live market data is worse than useless: you would paint a stale book for a few hundred milliseconds before the socket catches up, which is exactly the flicker you are trying to avoid. The data layer should treat every reconnect as a cold start. How that stream is debounced and coalesced once it is live is a separate concern, covered in throttling and batching market data in React.
A typed useLocalStorage hook
Everything funnels through one hook in src/state/state-storage.ts. It is generic over the stored type, reads lazily on first render, and wraps both the read and the write in try/catch so a parse error or a full quota degrades to the default instead of throwing.
import { useState, useEffect, useCallback } from "react"; export const useLocalStorage = <T>(key: string, initialValue: T) => { const readValue = useCallback(() => { if (typeof window === "undefined") { return initialValue; } try { const item = window.localStorage.getItem(key); return item ? (JSON.parse(item) as T) : initialValue; } catch (error) { console.warn(`Error reading localStorage key "${key}":`, error); return initialValue; } }, [key, initialValue]); const [storedValue, setStoredValue] = useState<T>(readValue); const setValue = useCallback( (value: T | ((val: T) => T)) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== "undefined") { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { console.warn(`Error setting localStorage key "${key}":`, error); } }, [key, storedValue], ); return [storedValue, setValue] as const; };
A few decisions are worth pulling out:
- The initial
useStatevalue is a function, notreadValue(). Passing the function defers the synchronouslocalStorage.getItem(and theJSON.parse) to the first render only, instead of running it on every render and throwing the result away. setValueaccepts an updater function, mirroringuseState, so callers can dosetValue((prev) => [...prev, id])without stale closures.JSON.parseis the one line most likely to blow up. A half-written value, a manual edit in devtools, or a value written by an older build can all produce something that does not parse. ThecatchreturnsinitialValueso the app boots with defaults rather than a white screen.
The genericity is the point. T flows through untouched, so useLocalStorage<Layout[]>(...) and useLocalStorage<string>(...) are both fully typed at the call site with no casting. If you want a deeper look at modelling this kind of state, TypeScript patterns for financial data in React covers the surrounding type design.
Versioned key prefixes
Every key in the demo is prefixed v2-. That prefix is not decoration; it is an escape hatch.
const version = "v2"; export const useLayouts = () => { const key = `${version}-layouts`; return useLocalStorage<Layout[]>(key, [primaryLayout, secondaryLayout]); }; export const useSelectedLayoutId = () => { const [layouts] = useLayouts(); const key = `${version}-selected-layout-id`; return useLocalStorage(key, layouts[0]?.data.id ?? ""); }; export const usePanelState = <T>(panelId: string, initialState: T) => { const key = `${version}-layout-panel-state-${panelId}`; return useLocalStorage<T>(key, initialState); };
So three families of key end up in storage:
| Key | Holds |
|---|---|
v2-layouts | Array of saved FlexLayout trees |
v2-selected-layout-id | The id of the active layout |
v2-layout-panel-state-${panelId} | One blob of state per panel instance |
The reason for the prefix is migration. localStorage has no schema. The moment you change the shape of a persisted value - rename a field, restructure the layout tree, change how a panel stores its config - every returning user has a payload on disk that matches the old shape. You have two options. You can write a migration that reads the old blob, transforms it, and rewrites it. Or, for a breaking change where the old data is not worth salvaging, you bump the prefix from v1 to v2 and the new keys simply do not exist yet, so useLocalStorage falls back to its defaults. The stale v1-* keys sit there harmlessly until you clear them.
That is exactly what the v2 in the demo records: an earlier layout shape was abandoned wholesale, and rather than write a migration for data nobody had invested much in yet, the keyspace was versioned forward. If you do want to preserve user data across a bump, the read path is the natural place to do it:
const migrate = (raw: unknown): Layout[] => { // detect the old shape, transform, and return the new one; // fall through to defaults if it is unrecognisable if (isV1LayoutArray(raw)) { return raw.map(upgradeV1Layout); } return [primaryLayout, secondaryLayout]; };
The defaults themselves live in src/state/state-default.ts: a primaryLayout with a 32/68 weight split between the favourites/balances column and the chart, and a secondaryLayout with a more even grid. Node ids are minted with uuid (v4) at definition time, so two browsers start from structurally identical but distinctly-identified trees.
Avoiding write storms
Here is where naive persistence falls apart. FlexLayout fires its onModelChange callback constantly while a splitter is being dragged - potentially dozens of times per second. If each call serialises the entire layout tree and writes it to localStorage, you get two problems at once: a synchronous write on the main thread per frame, and a state update that can re-render every panel in the workspace. On a busy trading screen that is a visible stutter.
The demo's src/layout/layout-model.ts deals with this by hashing the tree with object-hash (3.0.0) and comparing against the last hash before doing any work. Model.toJson() always returns a fresh object, so reference equality is useless; a structural hash is what tells you whether anything actually changed.
import objectHash from "object-hash"; const hashRef = useRef(objectHash(tree)); const onModelChange = useCallback( (changedModel: Model) => { const newTree = deserialize(changedModel.toJson().layout); // toJson always returns a new object, but the content // is often identical to what we already have. Compare first. const newHash = objectHash(newTree); if (hashRef.current === newHash) { return; } hashRef.current = newHash; updateLayoutTree(id, newTree); }, [id, updateLayoutTree], );
The same hash guards the inbound direction too: a useEffect keyed on tree recomputes the hash and only rebuilds the FlexLayout Model when it differs, which prevents a round-trip (state writes the model, model change writes the state) from re-rendering every panel. That re-render avoidance is its own subject; performant React for trading applications and the wider write-up on resizable, dockable panel layouts in React both go further into the FlexLayout re-render pitfalls (splitterSize is set to 4 pixels in the global config, for the curious).
Hashing collapses redundant writes, but for genuinely high-frequency changes - the splitter drag again, or a column resize - you also want to debounce the write itself, so you persist once when the gesture settles rather than on every intermediate value:
const useDebouncedLocalStorage = <T>(key: string, initial: T, delay = 300) => { const [value, setValue] = useLocalStorage<T>(key, initial); const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const setDebounced = useCallback( (next: T) => { clearTimeout(timer.current); timer.current = setTimeout(() => setValue(next), delay); }, [setValue, delay], ); return [value, setDebounced] as const; };
Note the split: the in-memory React state can update immediately for a responsive UI, while the comparatively expensive localStorage write trails behind on a debounce. The trade-off is that a hard crash within the debounce window loses the last few hundred milliseconds of changes, which for layout geometry is an acceptable loss.
Hydration: why a Vite SPA has it easy
Reading localStorage lazily during the initial useState works cleanly in the demo for one reason: it is a client-rendered SPA. There is no server render, so there is no first paint that disagrees with the second.
In a server-rendered framework like Next.js this is a trap. localStorage does not exist on the server, so the server renders with defaults; then the client reads storage and renders something different; React notices the mismatch and you get a hydration error (and a flash of the wrong layout). The typeof window === "undefined" guard in the hook stops the server from crashing, but it does not stop the mismatch - the server still emits the default markup. The standard fix is a mounted gate: render defaults on the server and on the very first client render, then switch to the stored value only after an effect confirms you are on the client.
const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); // On the server and first client render, `mounted` is false, // so both produce identical markup and hydration succeeds. if (!mounted) { return <LayoutSkeleton />; } return <Workspace layout={storedLayout} />;
Because Hedge UI ships as a Vite bundle, none of this applies and the hook can read synchronously on mount. It is worth knowing the difference before you lift this pattern into a Next.js codebase and wonder why the console is full of hydration warnings.
Multi-tab sync with storage events
A trader with the same app open in two tabs expects them not to fight. The storage event handles this for free: the browser fires it on other tabs of the same origin whenever a key changes, so a layout saved in tab A can be picked up in tab B without a refresh.
const onStorageChange = useCallback( (e: StorageEvent) => { if (e.key === key && e.newValue) { setStoredValue(JSON.parse(e.newValue)); } }, [key], ); useEffect(() => { window.addEventListener("storage", onStorageChange); return () => window.removeEventListener("storage", onStorageChange); }, [onStorageChange]);
Two gotchas worth internalising. First, the event does not fire in the tab that made the write, only in the others, so you never get an echo of your own change. Second, e.newValue is the raw string and still needs parsing - and ideally the same try/catch treatment, because a sibling tab running an older build could write a shape this tab cannot parse. The demo keeps the listener deliberately small and trusts the value; a stricter app would validate before adopting it.
Size limits and what stays in memory
The 5MB-per-origin ceiling is generous for layout trees and preferences - a couple of serialised FlexLayout trees are measured in kilobytes - but it is a hard, synchronous wall. Hitting it throws a QuotaExceededError, which is precisely why every write in the hook sits inside try/catch: a failed persist should downgrade to "this session is not saved" rather than crash the trade ticket. If you ever need to store something genuinely large (cached candle history, say), reach for IndexedDB instead and keep localStorage for small, structured preference data.
It is also worth being honest about what the demo does not persist today. The ThemeProvider in src/theme/theme-provider.tsx defaults to dark and toggles the class on document.documentElement, but it does not currently write the choice back to storage, so a theme switch does not survive a reload. (next-themes is in the dependency list but unused; the provider is hand-rolled.) Likewise the FavoritesProvider holds its watchlist - BTCUSDT, ETHUSDT, SOLUSDT, XRPUSDT by default - in plain useState, so edits are in-memory only. Both are obvious candidates to route through useLocalStorage (useLocalStorage<Theme>("v2-theme", "dark") is a one-liner), and the design reasoning for the theme side is covered in designing dark mode for trading interfaces. Leaving them unpersisted in the starter is a deliberate "wire this up to your own preferences backend" seam rather than an oversight, but it is the kind of gap a checklist catches.
Closing checklist
Before you call workspace persistence done, walk this list:
- Persist deliberate work, never derived or live data. Layouts and preferences yes; the order book no.
- Funnel every read and write through one typed hook so serialisation and error handling live in exactly one place.
- Wrap
JSON.parseandsetItemintry/catchand fall back to defaults. Stale or corrupt data must not white-screen the app. - Version your keys with a prefix. Decide per change whether to migrate the old shape or abandon it by bumping the prefix.
- Hash before you write so identical values do not cause redundant writes or re-renders, and debounce high-frequency gestures like splitter drags.
- Gate behind a mounted check on SSR frameworks. A Vite SPA can read synchronously; Next.js cannot without a hydration mismatch.
- Handle the
storageevent for multi-tab consistency, and parse itsnewValuedefensively. - Respect the ~5MB limit and push genuinely large data to IndexedDB.
Get these right and persistence becomes invisible, which is the whole goal - the user arranges their workspace once and the tool simply remembers. For the broader set of mistakes that make trading UIs feel cheap, what trading UI projects get wrong is a useful companion read.
