Forms & tables
<AutoForm> binds to a mutation, <DataTable> to a query — fields and columns from the Schema, validation and live updates for free.
Import components from @voltro/web, headless hooks from @voltro/client.
<AutoForm> — bind to a MUTATION
A form binds to one mutation's input Schema. This cleanly resolves
create-vs-update: they're different mutations with different input schemas, so
they're naturally different forms — no "CRUD mode" switch.
import { AutoForm } from '@voltro/ui'
// fields from the mutation's input schema; client+server share the schema;
// submits via the mutation with op-correct optimistic (insert prepends, etc.)
<AutoForm api="app" mutation="todos.create" onSuccess={(t) => navigate(`/todos/${t.id}`)} />
// update: bind the update mutation + pass the existing row
<AutoForm api="app" mutation="todos.update" defaults={row} />Here it is live — a real <AutoForm> bound to a throwaway demo API (your
own sandbox; data resets on refresh). Add a todo and watch it appear in the
table below, pushed from the server:
<AutoForm api="docs" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="docs" query="todos.list" />
A combined create-or-edit screen is a three-line wrapper:
{row
? <AutoForm api="app" mutation="todos.update" defaults={row} />
: <AutoForm api="app" mutation="todos.create" />}The customization ladder
- Rung 0 — zero config. Fields render from the schema: a
Schema.Literalunion →select; string → text; boolean → checkbox;Schema.Date→ date; a nested struct → a real<fieldset>SECTION with its legend and dotted fields; widgets receiveonBlur, so errors reveal on leave-field exactly like the headless binding. - Rung 1 — one custom widget via a
<Field>render-prop:import { AutoForm, Field, AsyncSelect } from '@voltro/ui' <AutoForm api="app" mutation="todos.create"> {() => ( <> <Field name="title" /> {/* auto */} <Field name="assigneeId"> {(w) => <AsyncSelect {...w} api="app" source="users.search" />} {/* live picker */} </Field> </> )} </AutoForm> - Rung 2 — swap a widget kind app-wide via the registry:
import { WidgetRegistryProvider } from '@voltro/web' import { shadcnWidgets } from '@voltro/ui-shadcn' <WidgetRegistryProvider widgets={shadcnWidgets}>{/* AutoForms render styled */}</WidgetRegistryProvider> - Rung 3 — own the layout. Pass children + arrange
<Field>s (columns, sections, tabs); each still auto-renders. - Eject (headless).
useFormBinding('app', 'todos.create')returns{ fields, values, errors, formError, isValid, pending, setValue, submit, reset }for 100% custom JSX — the binding stays.
Accessible by default
The built-in widgets render accessible HTML without any extra work: a <label htmlFor> tied to the control, aria-required on required fields (the visual * is aria-hidden — assistive tech learns "required" from the control, not from a spoken "star"), and, on error, aria-invalid + a role="alert" message associated via aria-describedby. A radio group ties its error to the whole <fieldset>.
Add help text by putting a description on the field's Schema — it renders as a hint and is associated to the control via aria-describedby, so a screen reader announces it with the label:
Schema.Struct({
handle: Schema.String.annotations({ description: 'Shown on your public profile' }),
})Validation — one schema, translated messages, server field errors
Client and server validate the SAME input schema, and the messages a user
sees are structured, not developer text. Every failed check maps to a stable
message id with params — validation.required, validation.minLength {min},
validation.minValue {min} — rendered through a built-in en/de catalog. The
locale follows <html lang> (the framework's locale contract); override it
per form with locale:, or wire your own catalog in one line:
// ONCE, at the app root — every form under it resolves ids through your catalog
<ValidationMessagesProvider messages={(id, params) => t(id, params)}>
<App />
</ValidationMessagesProvider>A per-form messages: option still exists and wins over the provider; a
resolver returning undefined falls through to the built-ins per id, so a
partial catalog costs nothing.
A widget kit that resolves ids itself reads the same resolver with
useValidationMessages(). It returns the provider's function, or
undefined when no provider is mounted — so a kit can fall through to the
built-in catalog instead of shipping its own.
Errors key the FULL field path (address.city, entries.0.startsAt), and a
message annotation on a schema may BE an id with params — as may the issues
a struct-level filter returns, which land at THEIR field:
const Input = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(2)),
startsAt: Schema.Number,
endsAt: Schema.Number,
}).pipe(
Schema.filter((v) =>
v.startsAt < v.endsAt ? undefined : [{ path: ['endsAt'], message: 'validation.beforeStart' }],
),
)The ids, and the shapes worth knowing. required, minLength {min},
maxLength {max}, betweenLength {min,max}, exactLength {amount},
pattern, invalidEmail / invalidUrl / invalidUuid, minValue {min},
maxValue {max}, minDate {min} / maxDate {max}, minItems / maxItems,
integer, invalid, checking, invalidFileType / fileTooLarge.
Three of those exist because the generic answer is worse at the point of use:
- Two length bounds on one field are ONE statement.
minLength(2)+maxLength(50)producebetweenLength {min,max}— not "at least 2" for a field whose rule is "between 2 and 50" — andlength(4)producesexactLength {amount}. - A declared
formatnames the rule. A regex never does: "Invalid format" beside an email box tells nobody anything. Annotate the format and the id gets specific —Schema.String.pipe(Schema.pattern(EMAIL)) .annotations({ jsonSchema: { format: 'email' } })→validation.invalidEmail. - A date bound is not a number bound.
minDate/maxDaterather thanminValuereading "must be at least 2026-01-01".
Counting rules pass count. minItems / maxItems carry { count }
(alongside min/max) because that is the parameter an i18n layer selects a
plural form on — i18next keys pluralisation on a parameter named exactly
count, so a message carrying only {min} cannot be pluralised at all.
Server-side rules route to their field too. An executor raises a typed
field error through the always-present ctx.validation — no declaration
needed, ValidationError is auto-merged into every mutation's and action's
wire error union exactly like ScopeError:
// executor
if (await emailTaken(input.email)) {
return yield* ctx.validation.fail('email', 'validation.emailTaken')
}
yield* ctx.validation.require(input.startsAt < input.endsAt, 'endsAt', 'validation.beforeStart')
// several at once: ctx.validation.failAll([{ field, message }, …])The binding routes it: the message lands in errors.email (translated
through the same catalog), the form stays editable, and submitError only
carries what NO field can — a BusinessRuleViolation whose rule pinpointed
a field routes the same way. Custom widget kits get the same judgement via
fieldIssuesOf(error) from @voltro/protocol.
Async checks gate the submit. Bind a uniqueness probe to its field and
submit waits for it — an invalid verdict blocks with the message on that
field, an unsettled check fails closed after 5s:
const email = useAsyncValidation('app', 'users.emailAvailable', values.email ?? '', {
interpret: (r) => ({ valid: (r as { available: boolean }).available, message: 'Email taken' }),
})
const form = useFormBinding('app', 'users.create', { asyncFields: { email } })The binding is typed — toInput included. createHooks<AppProcedures>('app')
returns useFormBinding beside the other hooks — tag as a literal type,
values/defaults and the submit output inferred from the descriptor. With
toInput, the form gets its OWN Values shape and the mapper's return is
checked against the mutation's input by the compiler — a mapping that stops
producing the wire shape is a type error, not a runtime refusal.
The full binding — nested values, arrays, timing, one state
useFormBinding carries a complete form, not just flat fields. Nested structs
flatten into SECTIONS (address.city, grouped under section('address')),
arrays of structs become field arrays, and every field is reachable as a
bound handle a widget kit spreads onto its input:
const form = useFormBinding('app', 'employees.update', {
defaults: fromRow(employee),
toInput: (values) => ({ id: employee.id, ...employeePatch(values) }),
errorPath: { fullName: 'name' },
})
const city = form.field('address.city') // { value, setValue, onBlur, error, required, label, a11y, … }
form.array('entries').push({ startsAt: '' })
form.section('address') // the section's descriptors
form.state // { isDirty, canSubmit, isSubmitting, isSubmitSuccessful, submissionAttempts, errorCount, pending, … }
form.reset(nextDefaults) // switch the edited record without a remount
form.focusFirstInvalid()Error timing has defaults a form can trust: a form NEVER opens with
errors — a field reveals its error after ITS blur or after the first submit
attempt, then live (validate: { onChange: 'afterTouched' }; 'always' and
'never' exist). isValid/canSubmit always tell the truth underneath, so
the save button disables correctly while the user is not yet being scolded.
Values ≠ mutation input: toInput maps form values to the wire input
BEFORE validation; input-schema issues route back to form fields via
errorPath (same-name fields map automatically). For composed saves,
onSubmit: async ({ input, values, mutate }) => … owns the write and keeps
optimistic + error routing.
Per-field rendering: with subscribe: 'fields' the binding only
re-renders on submission-level changes, and each field component subscribes
narrowly:
const BoundField = ({ form, path }: { form: FormBinding<Record<string, unknown>, unknown>; path: string }) => {
const f = useFormField(form, path)
return <input id={f.a11y.id} value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} aria-invalid={f.a11y['aria-invalid']} />
}Annotate structure on the schema itself: formField({ section, order, label, widget }) rides an annotation, description becomes help text, and
Schema.Date / Schema.DateTimeUtc map to date/datetime widgets. The engine
underneath is an implementation detail — no engine type appears in the public
API, and production builds stub its devtools channel automatically.
When the FORM decides which mutation it is
Some forms only learn their target from what the user does: a calendar entry becomes a recurring series the moment "repeats" is ticked, and the series mutation takes eleven more fields than the single one.
Pass a function of the current values instead of a tag:
const form = useFormBinding<CalendarInput>(
'app',
(v) => (v.repeats ? 'calendarRecurringEvent.create' : 'calendarEntries.create'),
{ defaults: { title: '', repeats: false } },
)The schema in force follows the tag, so form.fields grows and shrinks with it
and validation always matches what will actually be submitted. The values do
not reset — the engine is constructed once and never rebuilt, so everything
typed before the switch survives it. That is the whole reason this exists:
deriving the tag outside the binding is not available (the values belong to the
binding and do not exist before it), and re-mounting with a different tag throws
the user's input away.
Two details worth knowing. On the first render there are no values yet, so the
function is called with your defaults. And the accessibility ids are pinned to
the first tag and stay there — they are DOM ids, and letting them move on the
keystroke that flips the branch would remount every field, taking the focus and
the caret with it.
Reference fields, uploads, and testing the form
Reference fields. Mark a schema field as a table reference and it renders
as a picker-shaped field whose VALUE stays the id (or id list — which is
exactly what a target's declared relations: consumes):
const EmployeesUpdateInput = Schema.Struct({
id: Schema.String,
storeId: Schema.String.annotations(formField({ reference: 'stores' })),
assignedStores: Schema.Array(Schema.String).annotations(formField({ reference: 'stores' })),
})The descriptor carries widget: 'reference' + reference: 'stores'; bind
the shipped <AsyncSelect> (or your own picker) to it via a render-prop —
the default registry deliberately renders the render-prop note, because a
live picker needs a query binding only the app can name.
Uploads as field values. useUpload returns a fileId; hold it in a
field and LINK it in the submit — the composed save is what onSubmit is
for, so the upload that never got attached cannot happen silently:
const form = useFormBinding('app', 'tasks.create', {
onSubmit: async ({ input, values, mutate }) => {
const row = await mutate(input)
// attachments: [...existing, ...uploaded fileIds] — linked HERE, not forgotten
return row
},
})Leaving a dirty form is guarded by the router: useBlocker(form.state.isDirty)
holds SPA navigations (Back button included) and arms the native
beforeunload prompt — see the routing docs.
Rich text — and who sanitizes it
A rich-text field is RichTextDocument. Use it in the mutation input and the
form renders an editor with no further annotation:
import { RichTextDocument } from '@voltro/web'
const ArticleUpdateInput = Schema.Struct({
id: Schema.String,
body: RichTextDocument,
})Display it with <RichTextView doc={article.body} />.
The contract, because "who sanitizes" is the whole question. The value is
not an HTML string — it is a closed document tree with a fixed set of node
types. There is no html node, no raw-markup escape hatch, no attribute bag,
so there is nothing to sanitize: anything that is not one of the declared nodes
simply fails to decode.
That makes the Schema decode the boundary — the server's existing,
non-bypassable input check, the same one every mutation input already passes
through. The guarantee is therefore not "somebody remembered to sanitize this
one"; it is that a document which reached your database is one of these shapes.
The rest follows from that:
- A link's
hrefis the one field pointing outward, and it is allowlisted:http(s),mailto:, a#fragment, a/path. Nothing else —javascript:anddata:are refused by the decode, and control characters/whitespace are stripped before the check, becausejava\tscript:navigates exactly likejavascript:. - Client-side sanitizing is not a security boundary and is not treated as one. The widget's parser runs in the browser for the editing experience; the browser is where an attacker sits, so every property it maintains is re-established by the decode on the server.
- Rendering never uses
dangerouslySetInnerHTML.<RichTextView>maps nodes to React elements and text to React children, so markup someone typed into the box is markup the reader SEES. It also drops an href that would not survive a decode — for the value that never went through one.
The built-in editor is a <textarea> over a small, closed markdown subset:
headings, **bold**, *italic*, `code`, [text](href), - lists,
> quotes and fenced code. Everything it does not recognise stays literal text.
That is also what makes the field work with JavaScript off — the textarea posts
source, /form/* parses it, the same decode validates it. Register your own
rich-text widget (rung 2) for a WYSIWYG; the stored value shape is unchanged.
Not the collaborative case. Concurrent, multi-writer editing is
crdtDoc() + useCrdtEditor (@voltro/local-first) — a CRDT bytes column, a
sync lane, Tiptap. This is the single-editor field: one column, one writer,
ordinary JSON your server can validate, index and diff.
Testing. renderFormBinding (from @voltro/testing/client) drives the
REAL binding against a fake api — fill, blur, submit, read the visible
errors; a mutation handler that throws ValidationError({ field })
exercises the same routing path a server refusal takes:
const form = await renderFormBinding('users.create', {
binding: { schema: UsersCreateInput },
mutation: () => { throw new ValidationError({ field: 'email', message: 'validation.emailTaken' }) },
})
await form.submit()
expect(form.errors()['email']).toBe('validation.emailTaken')Runs under jsdom (// @vitest-environment jsdom).
What submit does with a failure, and what never reaches the wire
submit() does not reject. A form calls it from an onSubmit handler that
cannot await it, so a rejection has nowhere to go but the console — the form
sits there looking saved while the failure is invisible. It resolves
undefined instead, and the failure is state: field-routable errors land on
their field, everything else in state.submitError, with an optional
onError for a toast. That covers a composed onSubmit too — a follow-up
write failing on its OWN mutation handle is a failure the binding never saw
before, and it is the common shape (create the row, then its first child).
Only declared keys are sent. A form almost always carries more than the
mutation declares — a display toggle, a repeat control, a file held before
upload — and the server has refused undeclared input fields since 0.37. The
binding restricts the payload to the keys the input schema declares, which is
the rule the no-JS path already followed (unknown keys are dropped), so the
two submit paths agree. In development it warns once, naming what it dropped,
because a genuinely misplaced field should still be visible. toInput remains
the place to say what the write actually takes.
setValue with an unchanged value is a no-op. Every React state source is
expected to behave that way, and this one did not: each call produced a fresh
values object, so an effect depending on values that re-set a field to the
value it already held never settled.
A widget kit that needs the whole form rather than one field reads it with
useFormBindingContext() — the same provider, one level up.
Server and browser derive the same form. A page rendered on the server
resolves the mutation's input schema exactly as the browser will, so field
lists, labels and required marks match and hydration holds. (voltro dev and
voltro start both hand the descriptors over before rendering.)
const form = useFormBinding('app', 'employees.update', {
toInput: (values) => ({ id, ...employeePatch(values) }),
onError: (error) => toast.error(String(error)), // optional; state.submitError always carries it
})
// A field component anywhere below — no binding threaded through as a prop
const City = () => {
const f = useFormField('address.city')
return <input value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} />
}Forms without JavaScript
On a server-rendered page, <AutoForm> works with JavaScript disabled — or not
yet loaded. It always renders action="/form/<mutationTag>" + method="post",
so the browser has a native form-POST fallback; with JavaScript, onSubmit
intercepts as before (the RPC path, optimistic — unchanged).
The /form/<mutationTag> endpoint is mounted by the WEB listener on both boot
paths (voltro dev AND voltro start). It maps the posted FormData against
the SAME input schema the RPC path decodes:
- a checkbox present →
true, absent →false ''on a number/date field → the field is omitted (an optional field stays absent; a required one reports "missing" — never a silent0)- arrays arrive as repeated keys (
getAllsemantics) - a non-numeric string passes through RAW, so the decode fails honestly (never
NaN) - unknown keys are dropped
Validation runs through the same validateFields as the client-side
validation, so the error texts are identical — in the request's language, not
in English. The handler resolves the locale from THIS request through the same
resolver that decided the surrounding page's language (voltro:locale cookie
› Accept-Language › the app's
defaultLocale); an app that
configures no locales gets en. Then:
Under URL-prefix i18n the referring URL wins over the cookie chain: a form on
/de/todos renders its errors in German even if the voltro:locale cookie says
otherwise, because there the prefix is which page you are on rather than a
preference. A first segment that is not a declared locale falls through to the
cookie chain.
- Success →
303 See Other(POST-redirect-GET): back to the submitting page, or toredirectTo(same-origin relative paths only; anything else is discarded). Reloading the target page never produces a second write. - Validation error →
422: the referer page is re-rendered in the same response, with the field errors and the entered values server-side in the same error UI (role="alert", aria unchanged) — sent withcache-control: no-store, past the ISR cache. An RPC error AFTER valid input (a guard, the server) renders as a form-level error (role="alert",data-voltro-form-error). - Multipart →
415("file uploads need JavaScript").
Three props exist for this path:
formKey?: string— distinguishes several forms on one page in the no-JS round-trip (default: the mutation tag); the 422 re-render fills only the submitted form.redirectTo?: string— where the success303goes (no-JS path only; with JavaScript,onSuccessapplies). A same-origin relative path.action?: false— renders noactionattribute, for pure static-hosting deploys (dist on a CDN withoutvoltro start), where/form/*does not exist.
On SSR pages, pass schema explicitly. Descriptor resolution
(descriptors[tag].input) is a client-runtime feature; the SSR render sees an
empty descriptor map and would render zero fields without the schema prop.
Recommended source: import the schema from a shared browser-safe file — the
same one the mutation uses. One schema, no drift.
export const renderMode = 'ssr' as const
<AutoForm api="app" mutation="notes.create" schema={noteInput} redirectTo="/thanks" />Three limits, and this list is complete:
- The no-JS ERROR display (the 422 re-render) works only on
ssr/isrpages — the server cannot re-render a static page with request state (fallback: a minimal error page). - Pure static-hosting deploys (dist on a CDN, no
voltro start) have no/form/endpoint — setaction={false}there. - File uploads stay JS-only (multipart →
415).
Security-wise, the endpoint forwards server-side to the api as POST /rpc —
auth middleware, guards and RPC interceptors run identically to the normal RPC
path; details, including the one boundary, in the
security overview.
Headless: useFormBinding now takes a flash option and returns a
formError field; useFormFlash(formKey) (@voltro/web) returns the flash —
on SSR from the request context, on the client from the
#__voltro_form_flash__ JSON script. Both are identical, so hydration is
deterministic.
<DataTable> — bind to a QUERY
Columns come from the query's output Schema; rows are a LIVE subscription
(update on any write, no refetch); headers sort client-side.
import { DataTable } from '@voltro/web'
<DataTable
api="app"
query="todos.list"
rowActions={(row) => <button onClick={() => del.mutate({ id: row.id })}>Delete</button>}
/>Live — the same demo todos, with per-row actions wired to the toggle + delete mutations. Open this page in a second tab and toggle or delete a row: the other tab updates instantly, no refetch — that's the reactive query, not a poll.
<DataTable
api="docs"
query="todos.list"
rowActions={(row) => (
<>
<button onClick={() => toggle.mutate({ id: row.id, done: !row.done })}>Done</button>
<button onClick={() => del.mutate({ id: row.id })}>Delete</button>
</>
)}
/>
Headless eject: useDataTable('app', 'todos.list', { initialSort, pageSize })
→ { columns, rows, loading, error, sort, toggleSort, loadMore, hasMore }.
pageSize opts into LIVE grow-the-window pagination (a "Load more" button; the
query applies .limit(input.limit)) — rows stay reactive as the window grows.
renderCell / rowKey / emptyText / loadMoreText cover the common overrides.
Query-bound pickers
A picker binds to a query the way a form binds to a mutation. <AsyncSelect>
(or the headless useQueryField) drives a debounced, LIVE typeahead off a
source query — the option list updates reactively when the rows change:
const picker = useQueryField('app', 'users.search', { labelField: 'name', valueField: 'id' })
// → { options, loading, term, search(term) }Three cases need zero config because the schema says enough: an enum →
select (the options ARE the union); a reference() column → defaults to
async-select on the conventional users.search; a create-on-the-fly combobox
= a picker bound to a query (search) AND a mutation (tags.create).
Skeletons — shape-matched loading, no CLS
While a bound query/mutation loads, render a placeholder shaped like the real thing — derived from the same descriptor, so the swap causes zero layout shift.
import { FormSkeleton, TableSkeleton } from '@voltro/web'
<FormSkeleton api="app" mutation="todos.create" /> {/* right field count */}
<TableSkeleton api="app" query="todos.list" rows={8} /> {/* right columns */}Filters — the read-side AutoForm
A query's INPUT Schema IS the filter spec. <QueryFilters> generates a control
per filterable input field; values map to the query input; the result + count
update LIVE (the query is a subscription).
import { QueryFilters } from '@voltro/web'
<QueryFilters api="app" query="todos.list">
{(f) => <DataTable api="app" query="todos.list" /* … */ />}
</QueryFilters>Headless core: useQueryFilters('app', 'todos.list') →
{ filters, values, setFilter, clear, rows, count, loading }.
The schema rule (load-bearing)
A descriptor's input MUST be effect/Schema — that's what the form, the wire,
and the capability map introspect. Never put Zod (or another validator) in a
*.mutation.ts / *.query.ts descriptor. Client-side EXTRA validation
(beyond the descriptor) may use any Standard-Schema validator.