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/web'
// 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;Date→ date; nested object → acustomplaceholder asking for a render-prop. - Rung 1 — one custom widget via a
<Field>render-prop:import { AutoForm, Field, AsyncSelect } from '@voltro/web' <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, isValid, pending, setValue, submit, reset }for 100% custom JSX — the binding stays.
<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.