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:
useField↔field()— one untyped subscriptionuseTypedField↔typedFields(store).key— registry-typedController/createTypedController(form)↔<Field>— form-driven visibility with a render callback/snippetuseFormState↔formState()— 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
useFormcreates the store once and pushesextin 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.