---
title: "The form engine"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The form engine

The form engine implements `useFormEngine`, the client half of
[server/client mirroring](/reactivity/mirroring-test-design), and the reactivity
placement rules of [protocol v1 §5/§7](/protocol/v1). Everything lives in
`packages/ui`: the JsonLogic evaluator in `src/json_logic.ts`, the engine
internals in `src/form/`, the hook in `src/hooks/use_form_engine.ts`.

## What the engine is for

A form descriptor arrives as a nested layout tree with a flat set of state
keys, per-node `reactive` blocks, and capability URLs. `useFormEngine` turns
that into live state plus one `FieldController` per field, so a field component
never parses a rule, never builds a URL, and never touches Inertia.

<!-- @sample-preamble
import { SchemaRenderer, useFormEngine } from '@adonia/ui'
import type { ResourceFormPageProps } from '@adonia/ui'
-->

```tsx
export function PostForm({ descriptor, state }: ResourceFormPageProps) {
  const engine = useFormEngine(descriptor, state, { action: '/admin/posts' })

  // `schema` is absent in index mode (protocol v1 §3), hence the guard.
  return descriptor.schema === undefined ? null : (
<SchemaRenderer
  node={descriptor.schema}
  controllers={engine.controllers}
  visible={engine.visible}
  required={engine.required}
  fieldOptions={engine.fieldOptions}
/>
  )
}
```

Returned: `state`, `controllers`, `errors`, `dirty`, `visible`, `disabled`,
`required`, `derived`, `fieldOptions`, `submit()`, `reset()`.

## Nothing here is authoritative

Every rule the engine evaluates is mirrored server-side in the compiled
validator and the fill set (§11.2). A tampered client can only make its own UI
wrong. The engine exists for latency — no round-trip per keystroke — not for
enforcement.

That constraint has a concrete consequence: the client must reach *the same
verdicts* as the server, or the user watches values change after saving. Both
sides therefore implement the same closed operator set, and
`packages/ui/tests/reactivity_fixtures.test.ts` runs the entire fixture corpus
(`docs/reactivity/fixtures/`, 23 files, 40 cases × both modes) through the
engine and asserts the engine's verdicts against the same expectations the
server suite asserts.

## The evaluator

Closed operator set (the reactivity contract): `var === !== > >= < <= in ! and or if`. Strict
equality only, structural for arrays and objects; relational operators return
`false` when either operand is `null`; JS truthiness, so `[]` and `{}` are
**truthy**. `var` reads dotted paths (`items.0.price`) and `./`-prefixed paths
resolve against a repeater row scope.

`src/json_logic.ts` is a verbatim port of
`packages/core/src/validation/json_logic.ts`, deliberately duplicated rather
than shared: `@adonia/ui` ships to the browser and must not depend on the
server runtime. `tests/json_logic.test.ts` imports **both** evaluators and runs
a rule battery through them, asserting identical values *and* identical
warnings — drift fails CI on the spot.

## State hygiene

`buildFormState` projects the incoming state onto exactly the descriptor's node
keys:

- a key with no node is **dropped** and can therefore never be submitted
  (§19 mass-assignment guard);
- a declared key the server omitted falls back to `props.default`, then `null`
  — the same "defaults merged under the submitted state" rule the server
  evaluates with (R1), so both sides see one identical evaluation state.

A field hidden by `visibleWhen` keeps its value in state (draft safety, §5:
hiding is not discarding) but is **not** submitted. The server excludes it from
the validator and the fill set anyway (R3), so omitting it keeps the two sides
describing the same record. This is a different mechanism from `canSee`, which
removes the node server-side before it ever reaches the wire.

## Derivations

`reactive.sets` entries run in document order. `source` defaults to the
declaring node's own key; `if` guards the entry; `slugify`/`uppercase`/
`lowercase`/`copy` are built in and `fn:<name>` resolves from
`registry.transform(name, fn)`. An unregistered transform is **skipped with a
warning** — fail closed, never `eval`.

One pass, no fixpoint: every `source` reads the pre-derivation state, so
`a → b → c` does not cascade within a single edit. That matches the server
exactly (R6).

Two moments apply derivations:

- **live**, debounced by the edited node's `reactive.live.debounceMs`, and
  scoped to that node's own entries — re-deriving every target on every
  keystroke would overwrite whatever field the user is currently typing into;
- **at submit**, a full pass over every node, because the server recomputes
  them at save time regardless. Sending the derived value is what makes the
  optimistic UI and the persisted row agree.

`engine.derived` exposes the full-pass map for the current state.

## Dependent options

`reactive.refetch.url` is a *reference* — the literal `'urls.options'` — never a
URL. The engine resolves it against the same node's `urls` map (§4), appends
only the declared `withState` keys as query parameters, and stores the response
under `engine.fieldOptions[key]`. `SchemaRenderer` overlays that onto
`props.options`, so a field component consumes late-arriving options exactly as
it consumes server-rendered ones and needs no engine awareness.

A node declaring `refetch` without the referenced capability is a compile error
server-side and a dev warning here. Responses may be a bare array or an
`{ options }` / `{ data }` envelope; anything else leaves the previous options
standing rather than blanking a working select.

That overlay is also how a component tells "waiting" from "empty": until the
response lands the node has NO `options` prop, and once it lands it has one —
empty or not. The built-in `select` reads exactly that to choose between a
loading state and an empty list (see
[field components](/field-components)), which is why neither the engine
nor the protocol needs a `loading` flag.

`reactive.refetchSchema: true` is the closure-visibility fallback for rules the
wire grammar cannot express: after the field settles, the engine issues
`router.reload({ only: ['descriptor'] })`. Local edits survive the swap — the
engine re-projects state onto the new node set instead of rebuilding it.

## Errors, dirtiness, submit

Validation failures arrive as the Inertia `errors` page prop, either as a named
`inputErrorsBag` or flattened; `extractInputErrors` unwraps both and the engine
maps them onto `controller.error` by state key.

`dirty` compares live state against the last server-supplied baseline by deep
value equality, so re-typing the same value is not a change. While dirty (and
not mid-submit) the engine confirms Inertia visits through a `before` listener
and installs a `beforeunload` handler; a submit navigates on purpose and is
never confirmed away. Pass `{ guard: false }` to opt out.

The Inertia half is confirmed by Adonia's own alert dialog: `PanelShell` mounts
`DirtyGuardDialog`, which registers itself through `registerDirtyGuardPrompt`. A
`before` listener has to answer synchronously, so the visit is cancelled, the
dialog awaited, and — if the user discards — the same visit replayed with the
guard suppressed for that one visit. A form rendered outside a shell has no
dialog mounted and falls back to `window.confirm`. The `beforeunload` half
cannot be customized: closing a tab or reloading always shows the browser's own
prompt with the browser's own copy.

## Reactive verdicts reach components as props

`FieldController` is ABI-frozen at
`{ value, setValue, error, disabled, readonly, urls, refetchOptions }`
(§13.2), so reactive requiredness and refetched options are overlaid onto
`props.required` and `props.options` by `SchemaRenderer`. A component that
already reads those props becomes reactive with no changes.

Source: https://adonia.pages.dev/form-engine/index.mdx
