Custom forms and validation
View as MarkdownA custom form composes live TableSchema metadata, TanStack Form draft state,
and the server write path. It should not create a second schema. Load existing
records through the focused
cached table reads guide.
Keep state ownership explicit
Section titled “Keep state ownership explicit”Use one owner for each kind of state:
| State | Owner |
|---|---|
| Draft values, dirty state, local validation, submit state | TanStack Form |
| Table metadata, semantic draft decoding, generated CRUD requests | Sapporta frontend APIs |
| Domain request shape, success destination, cache effects, layout | Application screen |
| Write policy, row scope, authoritative validation, transactions | Server route |
Build a metadata-derived form
Section titled “Build a metadata-derived form”import { useMemo } from "react";import { useForm } from "@tanstack/react-form";import { FormField, buildRecordFormFields, firstFormErrorMessage, useLookupStore,} from "@sapporta/frontend";
function TaskEditor({ task, table }: { task: Task; table: TableSchema }) { const lookups = useLookupStore(); const fields = useMemo( () => buildRecordFormFields({ table, lookups }), [lookups, table], ); const form = useForm({ defaultValues: { title: task.title, project_id: task.project_id, due_date: task.due_date, } as Record<string, unknown>, onSubmit: async ({ value }) => submitTask(value), });
return ( <form onSubmit={(event) => { event.preventDefault(); void form.handleSubmit().catch(() => undefined); }} > {fields.map((fieldModel) => ( <form.Field key={fieldModel.column.name} name={fieldModel.column.name}> {(field) => ( <FormField field={fieldModel} value={field.state.value} issue={firstFormErrorMessage(field.state.meta.errors)} onChange={field.handleChange} /> )} </form.Field> ))} <button type="submit">Save task</button> </form> );}buildRecordFormFields() derives editable fields, semantic control kinds,
select options, and scoped foreign-key lookups from the current table schema.
The form may render only a subset or arrange fields into domain sections. Use
fieldModelForColumn() and foreignKeyFieldModelForColumn() when the layout
names specific fields.
Workflow values that are not table columns remain ordinary TanStack Form fields.
Application-specific controls can consume a Sapporta field model without using
FormField when the default rendering does not fit.
Name the form instance’s type
Section titled “Name the form instance’s type”ReturnType<typeof useForm<MealDraft>> and ReactFormExtendedApi<MealDraft>
both fail — twelve type parameters, no defaults. Every form.Field under the
bad annotation then degrades to any. Wrap useForm in one hook per form and
name its return type:
function useMealDraftForm(defaults: MealDraft) { return useForm({/* ... */});}
type MealForm = ReturnType<typeof useMealDraftForm>;Keep validators and onSubmit inside the hook so MealForm covers them. Reach
for createFormHook/withForm only when pieces are shared across forms.
Validate with a Standard Schema
Section titled “Validate with a Standard Schema”validators: { onChange: schema } checks the schema’s input type and
discards the transformed output — z.coerce, .trim() and friends report the
draft valid and leave the form value exactly as typed. Re-parse inside
onSubmit when the server needs the transformed value.
Where fields come from table metadata, prefer no schema at all:
parseCreateDraft() already reports required and invalid columns from the live
TableSchema.
Decode creates at submit time
Section titled “Decode creates at submit time”For an ordinary one-table create, keep raw form text until submit and call
parseCreateDraft() once:
const parsed = parseCreateDraft(table, value);if (!parsed.ok) { throw new FormSubmissionError(parsed.issues);}
await createTableRow(table.name, parsed.value);This decode preserves incomplete editor text during interaction. It omits
optional empty non-text fields, preserves empty text as "", canonicalizes
valid semantic values, and reports required or invalid fields. It does not
replace server validation.
Update forms need an explicit patch transform. Create omission and patch omission have different meanings. A create omission permits a default; a patch omission leaves the stored field unchanged.
Use an application typed endpoint when one submit changes several tables or performs a named domain action. TanStack Form still owns the draft, but the application contract owns the request shape and the server owns the transaction.
Map local and remote issues
Section titled “Map local and remote issues”fieldIssuesForSubmissionError() handles local FormSubmissionError values and
recognized Sapporta API validation details. Nested paths remain names such as
lines.0.quantity. Application code maps server fields when its form uses a
different field vocabulary.
Inside onSubmit, catch the rejection, convert the issues, and call
formApi.setErrorMap({ onSubmit: { form, fields } }). Keep a form-level
fallback for transport failures and details that do not name one field. Clear
stale errors from one form-level listeners.onChange rather than per-field
handlers, and catch the promise returned by form.handleSubmit() after the
errors have been rendered.
Never write from validators.onSubmitAsync — the row exists by the time the
validator reports failure. Use it only for read-only pre-write checks such as a
uniqueness probe.
TanStack Form idioms
Section titled “TanStack Form idioms”Sapporta composes TanStack Form with no wrapper or adapter, so the upstream React guides apply directly. Non-obvious points:
useSelector(form.store, selector)in logic,form.Subscribein JSX.useStoreis deprecated.isDirtynever resets — use!isDefaultValueto stop nagging once values match what was loaded.- Dependent fields: field
listeners.onChange+validators.onChangeListenTo, neveruseEffect. validationLogic: revalidateLogic()— validate on submit, then live.- Submit button:
aria-disabled, notdisabled; gate on!canSubmit || isPristine. - Also:
onSubmitInvalid,formOptions().