Save preferences on the device
A preference such as a view mode or a page size should stay when you move to another page, and still be there next time. Three definitions keep state inside the browser, each lasting differently. This page covers when to use defineLocalState, defineSessionState and defineMemoryState.
On this page
Keep it in localStorage
defineLocalState keeps state in localStorage. It stays until deleted, and every tab on the device sees the same values.
prefs.tsimport { defineLocalState } from '@k8ordo/state';
import * as z from 'zod';
export const prefs = defineLocalState(
'prefs',
z.object({
view: z.enum(['grid', 'table']).default('grid'),
pageSize: z.number().default(20),
}),
);view-switch.tsxconst [{ view }, update] = useAppState(prefs, ['view']);
update({ view: 'table' });The values are kept as one row under the key k8ordo-state:prefs, holding the JSON of the declared fields alone, such as {"view":"table","pageSize":20}. The definition exposes that key as storageKey.
The row goes through JSON, so keep the fields to what JSON can hold: a z.date() value shows right after the write and falls back to its default on the next load. As with entry, the schema must also accept its own output, so a boolean is z.boolean(), not z.stringbool().
The server has no localStorage, so the server render and the hydration render show the defaults before the saved values take over. To render the preference on the server, see “Preferences the server renders”. To apply it to <html> before the first paint, see “Read before hydration”.
Keep tabs in step
A local state reaches the other tabs open on the device: when one of them writes, the browser fires a storage event and the store reads the row again.
Only the components subscribed to a key that changed re-render. When another tab changes only pageSize, a component subscribed to ['view'] alone is left as it is.
Keep it in sessionStorage until the tab closes
defineSessionState is built the same way as defineLocalState, over sessionStorage instead.
notices.tsimport { defineSessionState } from '@k8ordo/state';
import * as z from 'zod';
export const notices = defineSessionState(
'notices',
z.object({ dismissed: z.array(z.string()).default([]) }),
);It survives a reload and goes when the tab closes. No other tab shares it, so the storage event only reaches other frames within the same tab.
The row and the way it is read are the same as localStorage’s, storageKey and inlineRead() included. It takes no version or migrate, though: rows go with the tab, and field-by-field salvage covers the rare one kept open across a deploy.
Different kinds are different states, even under the same key. A defineLocalState and a defineSessionState both named 'prefs' share neither a row nor a value.
Keep it in memory until the next reload
defineMemoryState is a typed shared box in the JavaScript runtime. Distant components share its values, and a reload puts them back to the initial ones.
palette.tsimport { defineMemoryState } from '@k8ordo/state';
export const palette = defineMemoryState('command-palette', {
open: false,
query: '',
});
type Panel = { tab: 'logs' | 'network' };
export const panel = defineMemoryState<Panel>('debug-panel', {
tab: 'logs',
});There is no schema. The values never leave the runtime, and the typed update() is their only writer, so there is nothing to check again. The type is inferred from the initial values; one they cannot tell, such as a union, goes in the type argument.
update() is never batched: each call applies on the spot. The server renders the initial values.
Pitfall
Replace values through update(); never mutate them. Whether something changed is decided by comparing what update() received with the previous value, so a nested object changed in place reaches no component.
See which ones stay
Real definitions, each keeping one number: in localStorage, in sessionStorage and in memory. All three have the same shape; only the place differs.
- localStorage
k8ordo-state:storage-demo-local0 - sessionStorage
k8ordo-state:storage-demo-session0 - Memorynot stored0
Try it
- Raise each number a few times, then reload the page. The localStorage and sessionStorage numbers stay, and the memory one goes back to 0.
- Copy the URL from the address bar and open it in a new tab. Only the localStorage number carries over; sessionStorage starts from 0.
- Raise the localStorage number in the new tab, then go back to the first one. Its number has changed to the same value.