Form Builder
Schema-driven, type-safe form builder for the OGMF frontend, built on TanStack Form, Zod, and shadcn Field primitives.
Introduction
The OGMF form builder turns a Zod schema plus a typed field-config map into a fully wired, accessible form. It lives at @/components/forms and is shared infrastructure — not a module. You describe what the form is (schema), how each field renders (config), and where the strings come from (i18n keys); the builder handles state, validation, layout, error resolution, and submission.
It replaces the older <ZodForm> + react-hook-form stack. Every <ZodForm> call site carried a cast — form as any or form as never — because the sub-section schema was narrower than the full form schema, and those casts hid whether the field names being passed actually existed. The new builder makes the field-name boundary type-safe: omitting a schema field or naming an unknown one is a compile error.
The schema, the field config, and the i18n dictionary each have exactly one job. The schema knows data shape and validation; the config knows presentation; i18n knows user-facing strings. No error message ever lives in the schema or the config.
What it solves
The old flow had three structural problems the builder is designed around:
| Pain point | How the builder fixes it |
|---|---|
No type safety at the field-name boundary (name="vatNumbr" reached runtime) | FormFields<typeof schema> requires an entry for every top-level schema field; unknown keys are a type error |
Schema and layout entangled (.meta({ row, col, colSpan }) read off the Zod schema) | Layout hints live in the field config, never on the schema — the same schema is reusable in non-UI contexts |
No compositional path (multi-card / multi-tab forms forced form as any again) | One form instance, distributed rendering — fields can sit in different cards, tabs, even dialogs and share one submission |
Tech stack
| Layer | Choice | Role |
|---|---|---|
| Form state | TanStack Form v1 (@tanstack/react-form) | The single store; field subscriptions; submit lifecycle |
| Validation | Zod 4 (zod/v4) via Standard Schema | One schema validates the whole form; issues route through an i18n error map |
| Field chrome | shadcn Radix Field stack | Field + FieldLabel + FieldDescription + FieldError wrap every input for ARIA and styling consistency |
| Layout | Tailwind 4 container queries | colSpan resolves against the parent form.Grid container width, not the viewport |
| Server state | TanStack Query (optional) | Submission can be wired to a useMutation result |
Layout responds to the container, not the viewport. The same form in a 400px sidebar and a 1200px page lays out differently because colSpan breakpoints (base/sm/md/lg/xl) resolve against the enclosing form.Grid.
Public API surface
Everything is re-exported explicitly from @/components/forms (no wildcard barrel). The full surface:
| Export | Kind | Purpose |
|---|---|---|
useFormBuilder | hook | The canonical API. Wires schema, validation, i18n, and submission; returns a typed form object. |
FormBuilder | component | Monolithic shortcut for trivial forms — renders every field in declaration order with one submit button. |
FORM_SLOTS | const | Slot names for modules contributing custom field types (FORM_SLOTS.FIELD_TYPES). |
FormFieldType | type | Contract a module's slot contribution must satisfy. |
FieldRenderer, FieldRendererProps | types | For typing a custom field-type renderer component. |
FormFields, StaticFormFields | types | The typed field-config map (and its predicate-free variant used inside array). |
FieldConfig, StaticFieldConfig, FieldConfigBase, FieldTypeRegistry | types | The field-config union, its static variant, the shared base, and the augmentable registry interface. |
FormApi, FormState, UseFormBuilderOptions, FormBuilderProps | types | The hook return, the reactive state shape, and the option/prop shapes. |
FieldHandle, SelectOption, ColSpan, ColSpanValue, FieldValuesPredicate | types | Headless field handle, choice option, column span, and predicate primitives. |
The form object — FormApi<TSchema>
useFormBuilder returns a form bound to the schema's value type, so field names autocomplete and selector slices are typed. Its members:
| Member | Purpose |
|---|---|
form.Provider | Wraps a subtree so descendant fields can read the form store via context. |
form.Fields | Renders the listed field names inside a Grid, respecting each field's colSpan. |
form.Field | Renders one field, with optional per-render colSpan / disabled overrides. |
form.Grid | The @container grid grid-cols-12 gap-4 root used by Fields. |
form.Section | Semantic grouping wrapper — Card chrome (default) or bare <fieldset> — each wrapping an inner Grid. |
form.Spacer | Empty grid cell reserving a column span. |
form.Break | Forces the next field onto a new grid row. |
form.SubmitButton | Submit button bound to submitting / validating / dirty state — disabled while pristine unless alwaysEnabled is set. |
form.SubmitButtonGroup | Primary + N secondary actions (e.g. "Save", "Save and New"), each with an optional onAfter. |
form.ErrorSummary | Opt-in summary of invalid fields with click-to-focus; hidden while the form is valid. |
form.useField(name) | Headless handle (FieldHandle) — the escape hatch for bespoke field chrome. |
form.useFormState(selector) | Subscribe to a slice of form state; re-renders only when the slice changes. |
form.submit() / form.reset() | Imperative submit and reset-to-defaults. |
There is no reactive form.state property. Read state through form.useFormState((s) => s.isSubmitting) — it is named useFormState (not useState) so destructuring const { useFormState } = form does not shadow React's useState.
The built-in field types
The 18 built-in field types are exactly the keys of the renderer registry. Each maps a type discriminator to its config shape in FieldTypeRegistry:
text | textarea | number |
currency | select | radio |
checkbox | checkboxGroup | switch |
combobox | asyncCombobox | hidden |
date | datetime | time |
dateRange | file | array |
There is no richText built-in — it was deliberately skipped (no current consumer needs it, and a speculative built-in would either bloat bundles or ship unused). A future module can add rich text through the custom-type slot mechanism. See Custom Field Types.
Custom field types beyond these are contributed by modules through the slot system — they become valid type values everywhere FormFields<T> is used via TypeScript declaration merging on FieldTypeRegistry.
Quick start
The smallest viable form uses the monolithic <FormBuilder> shortcut: every schema field renders in declaration order under a single submit button.
Define the schema (validation only)
import { z } from "zod/v4";
const schema = z.object({
name: z.string().min(1),
email: z.email(),
subscribe: z.boolean(),
});Describe each field
FormFields<typeof schema> requires one entry per top-level field. The label / placeholder values are i18n keys (resolved through the dictionary; shown here as literal strings the way the dev playground does, since it is dev-only).
import type { FormFields } from "@/components/forms";
const fields: FormFields<typeof schema> = {
name: { type: "text", label: "Name", placeholder: "Ana Silva" },
email: { type: "text", label: "Email", placeholder: "ana@example.com" },
subscribe: { type: "checkbox", label: "Subscribe to updates" },
};Render
import { FormBuilder } from "@/components/forms";
function SignupForm() {
return (
<FormBuilder
formId="signup"
schema={schema}
fields={fields}
onSubmit={(values) => console.log(values)}
submitLabel="Save"
/>
);
}For anything beyond a single flat column — multiple cards, tabs, a section layout, or per-render overrides — call useFormBuilder directly and compose the primitives:
import { useFormBuilder } from "@/components/forms";
function SignupForm() {
const form = useFormBuilder({ formId: "signup", schema, fields, onSubmit });
return (
<form.Provider>
<form.Section titleKey="signup.section.account">
<form.Fields names={["name", "email"]} />
</form.Section>
<form.Fields names={["subscribe"]} />
<form.ErrorSummary />
<form.SubmitButton>signup.save</form.SubmitButton>
</form.Provider>
);
}<FormBuilder> is not a separate code path — it calls the same hook and renders the same primitives, so every guarantee (validation, error map, mutation wiring, distributed rendering) holds for both.
Submission
Pass exactly one of mutation or onSubmit to useFormBuilder. With a mutation, the form calls mutation.mutateAsync(values); otherwise it calls your onSubmit(values).
const form = useFormBuilder({
formId: "customer",
schema,
fields,
mutation: useCreateCustomer(),
resetOnSuccess: true,
onSuccess: (values) => toast.success(`Saved ${values.name}`),
onError: (err) => console.error(err),
});The mutation / onSubmit choice is not enforced in the type system (to keep error messages readable). Providing neither — or both — throws at runtime. Provide exactly one.
A thrown submit error does not land in TanStack's form-level state.errors; the builder captures it and surfaces it via form.useFormState((s) => s.submitError). See Submission for the full lifecycle, SubmitButtonGroup, and onAfter.
Validation
Validation is the Zod schema. On change, blur, and submit, the whole schema is parsed and each issue is resolved to a localized string through a four-level i18n fallback convention (scoped-path-with-code → scoped-path → generic-code → Zod's localized default). Developers never write error strings in TypeScript.
Fields may also carry optional per-field validate (sync, runs on change) and validateAsync (runs on blur, receives an AbortSignal).
Async validators are skipped while sync validation still fails (TanStack's asyncAlways: false default). If the Zod schema or a field's own validate reports an error, validateAsync does not fire that cycle — a "check uniqueness" call only runs once the format is already valid. Async and sync errors do not stack.
See Validation for the error-map convention and per-field validators.
Conditional fields
disabled, required, and visible each accept a static boolean or a FieldValuesPredicate — a function over the whole values object that re-evaluates on every change. visible is the only one that unmounts the field; disabled and required flip ARIA flags only. required is a UI hint (the * indicator + aria-required) — hard validation always stays in Zod.
A hidden field keeps its stored value and is still validated on submit. A z.string().min(1) field that is currently hidden but empty will fail submit-time validation with no visible affordance to show the error. The schema branch where a field is hidden MUST permit its value — make it .optional() / .nullable(), or use z.discriminatedUnion for branch-conditional requirements. Otherwise the Submit button appears inert.
See Conditionals.
Arrays
The array field type binds a z.array(z.object(...)) schema to a repeatable fieldset. Each row renders via an item config — a StaticFormFields map keyed by the element schema's field names.
Item configs accept static booleans and arrays only. Function-form predicates (visible / disabled / required / options) inside item are a compile error, because the array primitive does not subscribe to values per row. Array field error and validator paths use bracket notation — contacts[0].value, not contacts.0.value.
See Arrays.
Custom field types
Built-ins are resolved first; a module extends the set by contributing a renderer to FORM_SLOTS.FIELD_TYPES from its module.ts, and declaration-merging FieldTypeRegistry so the new type value autocompletes in FormFields<T>. The contributed component receives { name, config, field } where field is TanStack's own AnyFieldApi. Built-ins win on a key collision.
slotContribution<FormFieldType>(FORM_SLOTS.FIELD_TYPES, {
type: "licensePlate",
component: LicensePlateField,
});See Custom Field Types.
Escape hatch
When the standard chrome is not enough, form.useField(name) returns a headless FieldHandle<TValue> — value, setValue, resolved error / errors, meta flags, and spreadable inputProps. Errors are already resolved against the i18n map; the handle exposes them as plain strings. See Escape Hatch.
Feature map
| Page | Covers |
|---|---|
| Field Types | All 18 built-ins and their per-type config (options, min/max, accept, queryKey/queryFn, etc.) |
| Layout | Grid, colSpan container queries, Section, Spacer, Break |
| Arrays | The array field type, item configs, bracket-notation paths, minItems / maxItems |
| Conditionals | visible / disabled / required / function-form options, and the schema contract for hidden fields |
| Validation | The i18n error-map convention, per-field validate / validateAsync |
| Submission | mutation vs onSubmit, SubmitButton, SubmitButtonGroup, ErrorSummary, submitError |
| Custom Field Types | The slot system and FieldTypeRegistry augmentation |
| Escape Hatch | form.useField and the FieldHandle shape |
Out of scope
The builder is deliberately bounded. It does not provide:
- A visual / drag-and-drop form designer — forms are authored in code.
- Backend-defined forms — the schema and config are TypeScript, not a server payload.
- Multi-step wizards — composition spans cards and tabs within one submission, not a stepped flow controller.
- An upload pipeline — the
filefield commits rawFileobjects to form state; the submission handler is responsible for uploading and replacing them with whatever the API expects.
For the file field, accept="image/*,.pdf" means "image MIME types narrowed by the .pdf extension" — which matches nothing. Use accept="image/*,application/pdf" for a true "images OR PDFs".