form-graph

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), or null while the key is inactive in the current branch. In the core, value is unknown and meta untyped — per-key types live in the bindings (typedFields, useTypedField, the svelte field<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's reconcile rules, 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's syncExt).
  • 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.