Svelte binding
form-graph/svelte bridges the store into Svelte 5 reactivity via createSubscriber. Each helper returns an object whose current getter is
reactive: reading it inside an effect or template subscribes, and the subscription fires only when
the underlying reference changes.
<script lang="ts">
import { field, formState } from 'form-graph/svelte';
import { form } from './my-form';
const store = form.createStore({ ext });
const prompt = field<string>(store, 'prompt');
const steps = field<number, { min: number; max: number }>(store, 'steps');
</script>
{#if prompt.current}
<input
value={prompt.current.value}
oninput={(e) => store.set({ prompt: e.currentTarget.value })}
/>
{#if prompt.current.error}
<span>{prompt.current.error.message}</span>
{/if}
{/if}
{#if steps.current}
<input
type="range"
min={steps.current.meta.min}
max={steps.current.meta.max}
value={steps.current.value}
oninput={(e) => store.set({ steps: Number(e.currentTarget.value) })}
/>
{/if} Isolation
field() subscribes per key, and the store's diff preserves references for
structurally-unchanged fields — so typing in prompt wakes only prompt's
effects, even though every keystroke recomputes the full snapshot. current is null while the field is inactive in the current branch; when the branch returns, the
remembered value comes back with it.
const state = formState(store); // whole-snapshot reactivity — prefer field() for controls
Typed fields — from the graph, not a registry export
The graph's registry types every key — function-defined (conditional) fields included — and typedFields(store) reads it back; the page imports nothing but the form:
// my-form.ts
export const form = defineGraph()
.field('aspectRatio', enumOf({ ... }))
.field('steps', (c) => (c.mode === 'create' ? slider({ min: 1, max: 50 }) : null));
// +page.svelte
const store = form.createStore();
const f = typedFields(store);
f.steps.current
// FieldSnapshot<number, SliderDefMeta> | null — inferred, no annotation <Field> — the form drives visibility
Place <Field> flat on the page; it renders its snippet only while the
current branch has the key, with the snapshot and a typed setter as snippet arguments. No {#if} re-stating branch logic the resolver already owns:
<Field {store} name="steps">
{#snippet children(snap, setValue)}
<input
type="range"
min={snap.meta.min}
max={snap.meta.max}
value={snap.value}
oninput={(e) => setValue(Number(e.currentTarget.value))}
/>
{/snippet}
</Field> The demos are built entirely this way.
Lists: list and elementPath
<script lang="ts">
import { elementPath, field, list } from 'form-graph/svelte';
const runs = list(store, 'runs'); // field()'s sibling: SUBSCRIBES (the -Of family builds defs)
</script>
{#if runs.current}
{#each runs.current.ids as id (id)}
{@const p = elementPath('runs', id)}
<RunRow {store} {id} /> <!-- or inline: field(store, p('engine')) -->
{/each}
<button onclick={() => runs.current.add()}>Add</button>
{/if} list(store, key) subscribes to the MEMBERSHIP entry only — add/remove/reorder
wake it, an element edit never does — and exposes the ops
(add/remove/duplicate/move, refusal-based;
see Collections). elementPath('runs', id) returns a namer for that element's
dotted paths: p('engine') → runs[a1b2].engine, which every helper on
this page accepts (field, store.set, setError, validate). In a row component, derive the handles so a keyed row re-derives if
its id ever changes: const engine = $derived(field(store, p('engine'))). The invoice demo is the worked example.
syncExt — reactive ext, no hand-rolled guard
syncExt(store, () => ({ tier: data.tier, flags: data.flags })); Store creation needs no helper in Svelte (a component script runs once), but ext that follows
reactive inputs does: syncExt pushes at setup — catching ext that hydrated
BEFORE mount, the SvelteKit load/remount shape — and on every change of the getter's
dependencies. setExt itself no-ops on a deep-equal ext, so the unconditional
pushes are free and there is no shadow copy to drift.
Argument order: the svelte helpers are all store-first (list(store, key) like field(store, name)) because the store is always required — Svelte has no
ambient-store context for plain functions. React's useList(key, store?) is
key-first only because its store can be omitted via <FormProvider>.
Focus on error
<Field>'s snippet gains a third argument — spread it onto the focusable
element, and core's focusFirstError(store, keys?) jumps to the first offender in
declaration order (the wizard demo's "Next" does exactly
this). Hand-rendered inputs opt in with data-fg-field={key} directly.
<Field {store} name="title">
{#snippet children(snap, setValue, fieldProps)}
<input {...fieldProps} value={snap.value} oninput={(e) => setValue(e.currentTarget.value)} />
{/snippet}
</Field> Testing gotcha
Svelte 5 ships separate client and server runtimes. Under vitest, add resolve.conditions: ['browser'] — without it the server runtime loads,
where effects are inert, and every reactivity assertion silently sees zero runs.