Getting Started
Define a Zod schema, declare your fields, and render a form with useFormBuilder or the FormBuilder shortcut.
The OGMF form builder turns a Zod schema plus a typed field map into a fully wired form: validation, error messages, layout, and submission are all handled for you. You describe what the form contains (the schema) and how each field looks (the field config); the builder takes care of the rest.
There are two ways in:
useFormBuilder— a hook that returns aformobject. You compose the layout yourself from the returned primitives (form.Fields,form.Section,form.SubmitButton, …). Use this for anything beyond the trivial.<FormBuilder>— a one-shot component that renders every schema field in declaration order with a single submit button. Use it for simple forms.
Both share the same engine — every guarantee that holds for the hook (validation, error map, mutation wiring) holds for the shortcut, because the shortcut just calls the hook internally.
Everything is imported from a single barrel:
import {useFormBuilder, FormBuilder} from "@/components/forms";
import type {FormFields} from "@/components/forms";Quick start
Define a Zod schema
The schema is the single source of truth for the form's value shape and its validation rules. Use zod/v4.
import {z} from "zod/v4";
const schema = z.object({
title: z.string().min(3).max(80),
description: z.string().max(500),
age: z.number().int().min(0).max(150),
priority: z.enum(["low", "normal", "high"]),
accepted: z.boolean().refine((v) => v === true, "You must accept the terms"),
});Declare the fields
FormFields<typeof schema> is a typed map: it requires exactly one entry per top-level schema key. Omitting a key, or adding one the schema doesn't have, is a compile error — so your field map can never drift out of sync with the schema.
import type {FormFields} from "@/components/forms";
const fields: FormFields<typeof schema> = {
title: {
type: "text",
label: "Title",
description: "Between 3 and 80 characters.",
placeholder: "A short headline…",
},
description: {
type: "textarea",
label: "Description",
rows: 4,
},
age: {
type: "number",
label: "Age",
min: 0,
max: 150,
step: 1,
},
priority: {
type: "select",
label: "Priority",
options: [
{label: "Low", value: "low"},
{label: "Normal", value: "normal"},
{label: "High", value: "high"},
],
},
accepted: {
type: "checkbox",
label: "I accept the terms",
},
};Render the form
Call useFormBuilder, wrap your layout in form.Provider, and drop in form.Fields plus a form.SubmitButton.
function MyForm() {
const form = useFormBuilder({
formId: "my-form",
schema,
fields,
defaultValues: {priority: "normal", accepted: false},
onSubmit: (values) => console.log(values),
});
return (
<form.Provider>
<form.Fields names={["title", "description", "age", "priority", "accepted"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);
}The names array passed to form.Fields is type-checked against the schema — only real field names autocomplete, and a typo fails the build. form.Fields renders the listed fields inside a 12-column grid, honoring each field's colSpan.
Configuring the form
useFormBuilder takes a single options object, UseFormBuilderOptions<TSchema>. The full surface:
| Option | Type | Required | Notes |
|---|---|---|---|
formId | string | yes | Prefix for the error-map convention — resolves Zod issue codes to i18n strings (e.g. customer.errors.name.too_small). |
schema | z.ZodObject | yes | The Zod object schema. Its inferred type drives field names, value types, and validation. |
fields | FormFields<typeof schema> | yes | One config entry per top-level schema key. |
defaultValues | Partial<z.infer<typeof schema>> | no | Initial values. Anything omitted starts empty and is coerced by the renderer. |
onSubmit | (values) => void | Promise<void> | one of these | Plain submit handler. Receives the parsed, valid values. |
mutation | UseMutationResult<…> | one of these | A React Query mutation; the form calls mutation.mutateAsync(values). |
resetOnSuccess | boolean | no | Reset the form to defaults after a successful submit. Defaults to false. |
onSuccess | (values) => void | no | Fires after a successful submit, before any reset. |
onError | (err) => void | no | Fires when the submit handler or mutation throws. |
Provide exactly one of onSubmit or mutation. The XOR isn't enforced in the type system (to keep the error messages readable), so it's checked at runtime — supplying neither throws on submit with a message pointing at your formId.
When you pass a mutation, submission delegates to it; otherwise onSubmit runs. On success the builder calls onSuccess?.(values) and then resets if resetOnSuccess is set. If the handler throws, the builder captures the error, calls onError?.(err), and surfaces the thrown error through form state (see submitError below).
The form object
useFormBuilder returns a FormApi<TSchema> — a bundle of layout primitives and state helpers, all bound to your schema's value type. Field-name props autocomplete from the schema and state selectors are fully typed.
| Member | Kind | What it does |
|---|---|---|
Provider | component | Wraps a subtree so descendant primitives can find the form. Required around any form.* primitive. |
Fields | component | Renders the listed field names inside a Grid, each respecting its colSpan. Takes names. |
Field | component | Renders one field. Takes name, plus optional colSpan / disabled overrides. |
Grid | component | The @container 12-column grid that Fields lays out into. Use it directly when composing custom layouts. |
Section | component | Semantic grouping wrapper — Card chrome (as="card", default) or a bare <fieldset> (as="fieldset"). Optional titleKey. |
Spacer | component | An empty grid cell that reserves a column span. |
Break | component | Forces the next field onto a new grid row. |
SubmitButton | component | A submit button bound to the form's pending/validating state. Optional alwaysEnabled. |
SubmitButtonGroup | component | A primary action plus N secondary actions sharing one submit lifecycle. |
ErrorSummary | component | Opt-in list of invalid fields with click-to-focus. Renders nothing while the form is valid. |
useField | hook | Headless handle for one field — the escape hatch for bespoke chrome. |
useFormState | hook | Subscribe to a slice of form state with a selector; re-renders only when the slice changes. |
submit | function | () => Promise<void> — imperatively submit the form. |
reset | function | Reset to defaultValues and clear dirty/error state. |
There is no store or handleSubmit member on the returned object. To read state, use form.useFormState(selector); to submit imperatively, call form.submit().
Where primitives can live
A single form.Provider can wrap any subtree. The primitives inside it read the form from React context, so they don't have to be siblings — you can scatter form.Fields, form.Section, and form.SubmitButton across different cards, tabs, or dialogs, all driving the same form.
<form.Provider>
<form.Section titleKey="Contact details">
<form.Fields names={["name", "email"]} />
</form.Section>
<form.Section titleKey="Address">
<form.Fields names={["street", "city"]} />
</form.Section>
<form.ErrorSummary />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>Rendering a form.* primitive outside its form.Provider throws immediately, with a message telling you to wrap the subtree. This almost always means a form.Field slipped out of the provider tree.
Reading form state
form.useFormState(selector) subscribes to a slice of the live form state. Because it takes a selector, the component re-renders only when the selected value changes. The state it projects:
| Field | Type | Meaning |
|---|---|---|
values | z.infer<typeof schema> | The current values. |
errors | Record<string, string[]> | Resolved error messages keyed by field name. |
invalidFields | string[] | Names of fields currently in error. |
isDirty | boolean | Any field changed from its default. |
isSubmitting | boolean | A submit is in flight. |
isValidating | boolean | Any sync or async field validator is running. |
submitError | Error | null | The last error thrown by onSubmit / mutation. |
isSuccess | boolean | The most recent submit succeeded. |
const submitError = form.useFormState((s) => s.submitError);
return (
<form.Provider>
<form.Fields names={["title"]} />
{submitError ? (
<p className="text-destructive text-sm">{submitError.message}</p>
) : null}
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);submitError comes from the thrown handler/mutation error, not from Zod validation — validation errors render inline on each field (and optionally in form.ErrorSummary). It's named useFormState, not useState, so destructuring const {useFormState} = form won't shadow React's useState.
The FormBuilder shortcut
For a trivial form — every schema field rendered in declaration order, one submit button — skip the composition and use <FormBuilder>. It accepts the same options as useFormBuilder, plus an optional submitLabel.
import {FormBuilder} from "@/components/forms";
function QuickForm() {
return (
<FormBuilder
formId="quick-form"
schema={schema}
fields={fields}
defaultValues={{priority: "normal", accepted: false}}
onSubmit={(values) => console.log(values)}
submitLabel="Save"
/>
);
}That is exactly equivalent to the composed version below — FormBuilder calls useFormBuilder and renders every field key followed by one submit button:
function QuickForm() {
const form = useFormBuilder({
formId: "quick-form",
schema,
fields,
defaultValues: {priority: "normal", accepted: false},
onSubmit: (values) => console.log(values),
});
return (
<form.Provider>
<form.Fields names={["title", "description", "age", "priority", "accepted"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);
}Reach for useFormBuilder the moment you need multiple cards, tabs, sections, an error summary, or anything other than "all fields, then save." The shortcut has no escape hatch for layout.
Complete example
A full, self-contained form using the compositional API — schema, typed field map, submit handling, and a peek at the submitted values.
import {useState} from "react";
import {z} from "zod/v4";
import {useFormBuilder} from "@/components/forms";
import type {ReactElement} from "react";
import type {FormFields} from "@/components/forms";
const schema = z.object({
title: z.string().min(3).max(80),
description: z.string().max(500),
age: z.number().int().min(0).max(150),
priority: z.enum(["low", "normal", "high"]),
accepted: z.boolean().refine((v) => v === true, "You must accept the terms"),
});
const fields: FormFields<typeof schema> = {
title: {type: "text", label: "Title", placeholder: "A short headline…"},
description: {type: "textarea", label: "Description", rows: 4},
age: {type: "number", label: "Age", min: 0, max: 150, step: 1},
priority: {
type: "select",
label: "Priority",
options: [
{label: "Low", value: "low"},
{label: "Normal", value: "normal"},
{label: "High", value: "high"},
],
},
accepted: {type: "checkbox", label: "I accept the terms"},
};
function ExampleForm(): ReactElement {
const [lastSubmit, setLastSubmit] = useState<z.infer<typeof schema> | null>(null);
const form = useFormBuilder({
formId: "example-form",
schema,
fields,
defaultValues: {priority: "normal", accepted: false},
onSubmit: (values) => setLastSubmit(values),
});
return (
<div className="space-y-4">
<form.Provider>
<div className="rounded-md border p-4 space-y-4">
<form.Fields names={["title", "description", "age", "priority", "accepted"]} />
<form.SubmitButton>Save</form.SubmitButton>
</div>
</form.Provider>
{lastSubmit ? (
<pre className="rounded-md border bg-muted/40 p-3 text-xs">
{JSON.stringify(lastSubmit, null, 2)}
</pre>
) : null}
</div>
);
}
export default ExampleForm;This mirrors the live Fields Basic section of the form-builder playground, which demonstrates all five of these field types end to end.
Next steps
- Browse the field types reference for all built-in
typevalues (text,select,currency,date,file,array, and more) and their per-type config. - See layout for
Grid,Section,Spacer,Break, andcolSpan. - See validation for field-level
validate/validateAsync, the error-map convention, andErrorSummary. - See custom field types to contribute your own renderer through the slot system.