Storage
The store persists INTENT — the user's raw choices, scoped addresses included — not resolved state. Reload, and the same resolver over the same intent reproduces the same form, including memory for branches that aren't currently active.
persistedStorage
import { persistedStorage } from 'form-graph';
const storage = persistedStorage('my-app:quote'); // localStorage
const tabScoped = persistedStorage('my-app:draft', { session: true }); // sessionStorage
const slower = persistedStorage('my-app:big', { delayMs: 1000 }); // debounce (default 300ms)
const store = form.createStore({ storage }); - Writes are debounced;
pagehideand tab-hide flush pending writes. - SSR-safe: with no
windowit returnsundefined— andcreateStoreacceptsstorage: undefined, so one line works on both sides. - Load/save failures (quota, privacy mode, bad JSON) degrade to defaults; they never throw into the form.
dispose()flushes and detaches the listeners.
The adapter contract
Anything with these two methods is a storage backend — a server draft API, an in-memory test double:
interface StorageAdapter {
load(): Record<string, unknown> | undefined; // address -> raw value
save(intent: Record<string, unknown>): void;
}
// wrap any adapter with debouncing:
import { debouncedStorage } from 'form-graph';
const adapter = debouncedStorage(myServerAdapter, 500); What the record looks like
{
"region": "eu-west",
"instanceType@gpu": "gpu.a100", // scoped: remembered per preset
"instanceType@compute": "c2.xlarge",
"vcpus@gpu": 32
} Addresses are key@scope (scope parts joined with /, separators
escaped). Treat them as opaque keys — [ and ] are reserved for
future per-item addressing.
Graph-level scope — it NESTS
A field opts into scoped memory with scope on its definition. When a whole
SECTION of the form should bucket the same way, declare it once at construction — and
scope composes down the mount tree: a parent's segments prefix everything a mounted
child (or branch member) declares, so scope really does apply to everything below it.
const family = defineGraph<FamilyExt>({ scope: (ext) => ext.ecosystem })
.field('steps', STEPS) // -> steps@SDXL, steps@Flux, ...
.field('cfg', ({ model }) => ({ ...CFG, scope: [model.id] })) // APPENDS: cfg@SDXL/123
.field('seed', { ...SEED, scope: rootScope() }) // ESCAPE HATCH: bare, global
.use(defineGraph({ scope: () => 'textFields' }) // nests: fields at @SDXL/textFields
.field('prompt', PROMPT)); - The fn runs at resolve time with the graph's ext (a mounted child sees the parent's ctx merged in), so the bucket follows the live discriminant.
- Fields INHERIT the accumulated path; a field-level plain
scopeappends to it ([]appends nothing). rootScope(...parts)is the escape hatch — absolute from the root regardless of ancestry;rootScope()means the bare key (global memory).- Returning
undefinedfrom the graph fn contributes nothing — fields stay at the inherited path.
Reading a record from outside
External readers (a "restore last session?" banner, migration scripts) go through the intent readers instead of parsing addresses by hand:
import { readIntentValue, readIntentBuckets } from 'form-graph';
readIntentValue(stored, 'instanceType', 'gpu'); // scoped bucket, bare-key fallback
readIntentBuckets(stored, 'instanceType'); // { gpu: ..., compute: ... } Seeding a record from outside
The persisted format is one flat object of address → raw value, so a migration
from another storage system can build a record and write it under the store's key before the
first mount. scopedAddress is the write-side counterpart of the readers — it
applies the same escaping the store uses, so never glue key + '@' + part by hand:
import { scopedAddress } from 'form-graph';
const record = {
prompt: old.prompt, // bare key: global memory
[scopedAddress('steps', 'SDXL')]: old.sdxlSteps, // 'steps@SDXL'
};
localStorage.setItem('my-form', JSON.stringify(record)); Values are copied raw: the boundary input schemas validate them on first resolve, so a stale or malformed value degrades to the field's default instead of corrupting the store.