form-graph

Core concepts

Intent, not form state

The store's source of truth is intent: everything the user has ever chosen, keyed by address, never deleted when a field deactivates. The visible state is always a pure function of intent plus external context. Switch away from a branch and back — your values return, because nothing was thrown away.

The graph: declaration order is dependency order

A form is a chain of field definitions. Each definition function receives the accumulated context of the fields declared above it (plus the external context) — referencing a later or nonexistent field is a compile error, so dependencies can never be circular or misspelled. Returning null means the field does not exist this pass; the key goes optional.

defineGraph<Ext>()
  .field('mode', enumOf({ ... }))
  .field('steps', (c) => (c.mode === 'create' ? slider({ max: c._ext.maxSteps }) : null))

Graphs are immutable values: continuing one with .field() makes a new graph, so a shared prefix is ordinary chaining and a shared section is a plain Graph → Graph function. The one thing a chain can't express — alternative SHAPES with a discriminated union between them — is the branch combinator's job: a keyed table of member graphs (see the publish demo's hub).

Scoped memory

A definition can declare a scope, and its intent is then remembered per scope value — steps@flux and steps@sd are separate memories. This is how "the model you picked for Flux" survives a trip through SDXL. Reads fall back to the bare key, so unscoped writes (raw server input, remixes) still land.

// spread onto any def or helper — scope is a field option, not a codec property
.field('model', (c) => ({ ...checkpointDef(c), scope: c.ecosystemGroup }))
.field('steps', (c) => ({ ...slider({ min: 1, max: c.eco === 'flux' ? 2000 : 3000 }), scope: c.eco }))

Dual schemas and trust

Every value knows where it came from. UI writes are trusted and stored verbatim; boundary values (storage, URL, remix, raw server input) run the lenient input schema lazily. A lenient schema may REPAIR rather than reject — slider's input snaps an out-of-range value to the nearest step inside the bounds, on the theory that a ceiling that moved shouldn't erase the user's setting. Only a value the input schema rejects outright falls back to the default (the rejection surfaces as the field's snapshot error). Either way, a corrupt stored value can never wedge the form. The strict output schema runs only on demand: submit, output(), server parse() — and, with revalidate: 'touched', on every recompute for fields the user has written. The helpers cache schema construction on the exact values a schema is built from, so per-pass definitions cost object literals, not zod.

When the system disagrees with the user

Three reactions to an unacceptable value, each stated inside the definition:

  • gate (on enumOf) — availability, declared once: the option renders disabled AND a value sitting on it is corrected to the first open option, with the gate's string as the reason. For options that are temporarily not offered.
  • correct — the definition's correction policy: inspect the resolved value, substitute with a reason. The reason rides to the server as an audit note and onto the field's snapshot for inline display; intent is untouched, so the original choice returns when conditions do. For mismatches the SYSTEM caused — a retired model, a ceiling that moved.
  • the output schema itself — a refusal, in zod's own vocabulary: narrow output conditionally (.refine(...), .min(...), anything). The value keeps its place and fails submit with a per-field error — live once the user has written it, under revalidate: 'touched'. For mismatches the USER must resolve.
.field('storageClass', (c) => enumOf({
  options: CLASSES,
  default: 'standard',
  gate: { glacier: c.region === 'ap-northeast-1' && 'unavailable_in_region' },
}))

.field('vcpus', (c) => ({
  ...slider({ min: 2, max: 64, step: 2 }),
  meta: { min: 2, max: c._ext.tier === 'pro' ? 64 : 16, step: 2 },
  correct: (v) => (v > 16 && c._ext.tier !== 'pro'
    ? { value: 16, reason: 'tier_limit' }
    : undefined),
}))

.field('hazmatClass', (c) => ({
  ...HAZMAT, // cached base — narrow per pass with refine, never a rebuilt output
  refine: (output) => output.refine((v) => !(v === '1.4' && c.service === 'air'), {
    message: 'Class 1.4 explosives cannot ship by air',
  }),
}))

Effects (rules)

The one thing that runs on set() instead of during resolve: a rules unit rewrites the PATCH before it reaches intent — for couplings between two USER choices, where picking one implies the other (the rewritten patch is remembered as if the user chose it). A record keyed by the trigger field; one ordered pass per set(), each rule at most once, no rewind — cycles are structurally unrepresentable.

defineGraph()
  .field('model', MODEL)
  .effect({
    // a PLAIN MAP keyed by the trigger field — typed from the graph
    model: (model, { state }) =>
      model?.id === DRAFT_ID && state.workflow !== 'draft' ? { workflow: 'draft' } : undefined,
  });
// rules ride the graph: into a parent via .use, and into its own createStore()/parse()

Rule of thumb: an effect when a choice implies another choice; correct when the world changed under a choice; the output schema when the choice itself is unacceptable.

Render isolation under full recompute

Every pass re-resolves the whole graph, but snapshots preserve references for structurally-unchanged fields — so per-key subscribers wake only when their field actually changed. Typing in prompt recomputes everything and re-renders one control. Measured on the LTX generation graph: ~20µs per keystroke for the full resolve + diff.

Reusable definitions

There is no special machinery for reuse — definitions are values and graphs are values:

  • a def factory is a function returning a definition (toppingsDef(budget));
  • a section is a Graph → Graph function (withContact(g));
  • the same section mounts twice under key prefixes (withAddress(g, 'shipping'), withAddress(g, 'billing', when)) — see the checkout demo.