form-graph

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.