The store
form.createStore(options) is the client runtime: it holds intent, re-resolves on
every change, and hands out snapshots whose references are preserved for
structurally-unchanged fields — which is what makes per-field subscriptions cheap.
const store = form.createStore({
ext, // required iff the form declares an Ext
storage: persistedStorage('my-form'), // optional persistence (see Storage)
}); Reading
getSnapshot()—{ state, keys, fields }for the active branch.getState()— the resolved state object alone.getField(key)— one field's snapshot (value,meta,error,note,isComputed), ornullwhile the key is inactive in the current branch. In the core,valueisunknownandmetauntyped — per-key types live in the bindings (typedFields,useTypedField, the sveltefield<T, M>helper); headless code casts or goes through those.getNotes()— this resolution's notes (corrections and advisories).getIntent()— DURABLE intent (adopted defaults filtered), scoped addresses included; also the submit wire format (see Server parsing).getComputedKeys()— the derived keys in the active branch.isDirty()/dirtyFields()— what the user has WRITTEN this session (scope-collapsed field keys; list element paths as paths). Adopted defaults and storage-loaded values are the baseline, not dirt. Deliberate divergence from react-hook-form: typing the default back STAYS dirty — the write exists; we don't deep-compare defaults.
Subscribing
const off = store.subscribe(() => rerender()); // whole-form
const offOne = store.subscribe('steps', onSteps); // one key only A per-key subscriber fires only when that field's snapshot reference changes. Typing in prompt recomputes the whole form, but only prompt's subscribers
wake. The framework bindings are thin wrappers over exactly this.
Writing
set(patch)— the one write path for user input. The patch runs through the form'sreconcilerules, lands in intent (through each definition's input schema), and triggers a resolve.setExt(ext)— replace the external context wholesale; the form re-resolves. Ext is never mutated in place — the store cannot see mutation. A deep-equal ext is a no-op, so pushing unconditionally from a reactive source is free (see the Svelte binding'ssyncExt).reset({ exclude })— clears intent, surfaced and external errors, and touched state. Excluded KEYS keep everything they've accumulated, every scoped bucket included.prune(predicate)— delete intent entries by address, for targeted cleanup.
When errors surface: revalidate
const store = form.createStore({ ext, revalidate: 'touched' }); Default ('submit'): refine/output failures show only at validate()/parse — a pristine required field never scolds. 'touched': a field the user has WRITTEN is judged on every recompute — new
failures surface live and lift live, still only for touched fields, so pristine fields
stay quiet in both modes. Touched means written: the store is UI-blind, so blur is
not a store concept (a binding can layer blur-based touch later if needed). Cost, measured:
the worst case — every field of a 35-field form touched — adds ~6µs to a keystroke.
External errors: async judgments in a sync engine
store.setError('triggerWord', { message: 'This phrase is not allowed.' });
store.clearError('triggerWord'); Resolution stays synchronous; async results enter as VALUES through set/ext, and
as VALIDITY through setError — a server-side audit refusing a field, a cost check
failing a row. An external error is live on the field's snapshot, wins over an engine error on
the same key, fails validate()/output(), and is never persisted.
Staleness the engine owns: a user write to the field clears it, the field leaving the active
branch drops it, and setError against an inactive key binds nothing. Everything
else — clearing on a new request, choosing which async result wins — is your code, where you
already know the answer.
Getting data out
const result = store.validate(); // { success, data | errors } — the checked path
const data = store.output(); // Data — same shape as parse().data; THROWS naming failing keys
const part = form.parsePartial(raw, ext); // best-effort: per-key results, no throw validate() is for submit flows that render errors; output() is for
call sites that have already validated and want the narrowing; parsePartial is
for progressive server handling. Server-side form.parse(raw, ext) is the same
pipeline over a raw record — one behavior, client and server.
Scoped validation: wizard steps
store.validate(); // the whole active graph
store.validate('triggerWord'); // one field
store.validate(STEP2_KEYS); // a step's fields — a wizard "Next"
store.validate('runs'); // a whole list, every element The scoped form judges ONLY the named fields — surfacing and clearing errors for them alone,
so a step's "Next" can't scold (or absolve) a later step. Keys are typed against the graph;
an inactive key is vacuously valid, which is what lets one key list cover every branch arm. A
list key expands to all its elements. The scoped result is { success } or { success: false, errors } —
deliberately without data: a scoped success vouches only for the named fields.
The graph itself stays step-agnostic; which keys form a step is your business, declared next
to the step's UI (see the wizard demo). After a failed step
validate, focusFirstError(store, keys) jumps to the first offender — see the
bindings' fieldProps.
Lists
const runs = store.list('runs'); // throws for an inactive/unknown key
runs.ids // element ids, in order
runs.add({ engine: 'musubi' }) // { success: true, id } — seed writes member fields
runs.remove(id) // { success: false, reason: 'min' } at the bound
runs.duplicate(id) // copies USER-WRITTEN entries; derived values re-derive
runs.move(id, index) The ops handle for a list() field (see Collections). Bounds REFUSE rather than
break: the store never holds an invalid membership, and a refusal carries its reason. Element fields read and write through their dotted paths
(store.set({ 'runs[a1b2].engine': 'musubi' })) — every store mechanism
on this page is path-aware.