form-graph

React binding

form-graph/react wraps the store in useSyncExternalStore. Same contract as the Svelte binding: per-field subscriptions, reference-equality bailout, null while a field is inactive in the current branch.

import { useForm, useField, useFormStore, FormProvider, Controller } from 'form-graph/react';
import { form } from './my-form';

function GenerationForm({ ext }) {
  const store = useForm(form, { ext });

  return (
    <FormProvider store={store}>
      <PromptInput />
      <Controller
        graph={form}
        name="steps"
        render={({ value, meta, error, onChange }) => (
          <Slider min={meta.min} max={meta.max} value={value} onChange={onChange} />
        )}
      />
    </FormProvider>
  );
}

function PromptInput() {
  const store = useFormStore();
  const field = useField<string>(store, 'prompt');
  if (!field) return null;
  return (
    <input value={field.value} onChange={(e) => store.set({ prompt: e.target.value })} />
  );
}

The render props are value, meta, error, onChange, isComputed, fieldProps (see Focus on error), and note — the resolution note set when a correct rule replaced the value this pass, for rendering a "we adjusted this" hint inline.

Typed fields

useTypedField(store, name) derives the snapshot's value/meta types from the form's def registry, carried on the store — the hook twin of Svelte's typedFields:

const steps = useTypedField(store, 'steps');
// FieldSnapshot<number, SliderDefMeta> | null — inferred, no annotations

The React ↔ Svelte mapping

Same concepts, each framework's idiom:

  • useFieldfield() — one untyped subscription
  • useTypedFieldtypedFields(store).key — registry-typed
  • Controller / createTypedController(form)<Field> — form-driven visibility with a render callback/snippet
  • useFormStateformState() — whole-snapshot

Typed controllers: the graph prop

Pass the graph (type-only — the store still comes from context or the store prop) and name is constrained to the graph's keys: registry fields AND state keys (computeds, branch tags), with value/meta narrowed per key. A state-only key types meta as undefined and its value as optional across branch arms.

<Controller graph={generationHub} name="steps"
  render={({ value, meta }) => /* number, { min, max } */} />
<Controller graph={videoHub} name="wanVersion"   // a branch TAG — state key
  render={({ value }) => /* 'v2.1' | 'v2.2' | ... | undefined */} />

createTypedController<typeof defs>() remains for registry-only typing, and the bare generic form (Controller<number, Meta>) as the escape hatch.

onChange accepts the field's INPUT type

With the graph prop, onChange is typed (next: Value | In) => void, where In is inferred from the def's input schema — a picker can hand back a bare { id } or a number and it typechecks, because that is what the schema declares it accepts and normalizes. This only works for defs that keep their concrete schema types (satisfies FieldDef<…>, never a return annotation — see Definitions); an annotated def falls back to In = Value, i.e. parsed-shaped writes only.

MultiController

One subscription over SEVERAL fields — for widgets that read a group (an alerts panel over model + resources + vae). Re-renders when any named field's snapshot reference changes:

<MultiController
  graph={imageHub}
  names={['model', 'resources', 'vae']}
  render={({ values }) => <ResourceAlerts {...values} />}
/>

Lists: useList and <ListElement>

function Runs() {
  const runs = useList('runs');            // context store; or useList('runs', store)
  if (!runs) return null;                  // list inactive in this branch
  return (
    <>
      {runs.ids.map((id) => <Row key={id} id={id} />)}
      <button onClick={() => runs.add()}>Add</button>
    </>
  );
}

const Row = memo(function Row({ id }: { id: string }) {
  return (
    <ListElement list="runs" id={id}>
      {/* bare member names — the element path prefixes them */}
      <Controller name="engine" render={...} />
      <Controller name="epochs" render={...} />
    </ListElement>
  );
});

useList subscribes to the MEMBERSHIP entry only: add/remove/reorder re-render the shell, an element edit never does. Inside <ListElement>, every existing hook and control works unchanged with bare member names — useField, Controller (reads AND writes), MultiController, nested useList — each subscribing to its own element, so a memo'd row bails on everything but its own data. That is the render-isolation contract (see Collections), pinned by a render-count test. The ops mirror store.list()'s, with one difference: a click landing after a branch switch deactivated the list returns { success: false, reason: 'inactive' } instead of throwing.

useList is context-first (useList(key, store?)) like Controller; useField(store, name) predates the provider and stays store-first. Element fields are shape-typed (the registry covers root fields only — see Collections' limits).

Focus on error

const result = store.validate(step.keys);
if (!result.success) focusFirstError(store, step.keys);

The render props carry fieldProps — spread it onto the control's focusable element (<input {...fieldProps} />; explicit by design, since not every render prop targets an element) and focusFirstError(store, keys?) jumps to the FIRST errored field in declaration order, scoped to a step's keys when given (a list key covers its elements). Works unchanged inside <ListElement> — the attribute carries the full element path. SSR-safe, never throws. A useField-based control opts in by rendering data-fg-field={name} itself — the attribute is the whole contract.

Notes

  • useForm creates the store once and pushes ext in only when it deeply changed — pass a fresh object literal every render without churn.
  • The context value is the store handle, which never changes identity — providers never re-render their subtree; only leaf subscriptions fire.