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.Literal union → select; string → text; boolean → checkbox; Date → date; nested object → a custom placeholder 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 enumselect (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.