Forms
Build a validated form with the useAppForm hook — TanStack Form for state, Zod for validation, on submit.
Every form in the Education Hub is the same three ideas wearing different field labels: TanStack Form holds the state, Zod validates it, and a React Query mutation sends it to the API. You never wire those together by hand — a single project hook, useAppForm, hands you a form object with the field components and submit button already bolted on.
The mental model is small: you describe the form's shape (default values), its rules (a Zod schema), and what happens on submit (fire a mutation). Everything else — tracking each field's value, surfacing errors, knowing when the form is dirty — the form object does for you.
The app form hook
useAppForm isn't a raw TanStack hook. It's built once in src/hooks/use-form.tsx with createFormHook, which lets you pre-wire the pieces every form reuses — the Input and Textarea field components, and a SubmitButton — so they're available on the form object without importing them again.
const { fieldContext, formContext } = createFormHookContexts()
export const { useAppForm, withForm } = createFormHook({
fieldComponents: { Input, Textarea },
formComponents: { SubmitButton },
fieldContext,
formContext,
})Why the SubmitButton wrapper exists
The project's base Button defaults to type="button", which would quietly block native form submission. SubmitButton is a thin wrapper that forces type="submit", so the button inside form.AppForm always triggers the form's onSubmit. That one-line fix is the whole reason the wrapper exists.
Building a form
Reach for useAppForm whenever you have data to collect. You give it three things, and the real edit-school-branding panel (src/components/side-panels/edit-school-branding.panel.tsx) shows all three at once — default values seeded from the API, a Zod schema, and an onSubmit that calls a mutation.
const schema = z.object({
primaryColor: z.string(),
landingTitle: z.string(),
// ...
})
const form = useAppForm({
defaultValues: { primaryColor: branding.primaryColor ?? '', /* ... */ },
validators: { onSubmit: schema },
onSubmit: ({ value }) => updateSchoolBranding({ id: schoolId, body: value }),
})You render each input with the render-prop form.Field. It hands you a field object that owns one slice of state — its current value and a typed change handler — so the input stays controlled without you declaring any useState.
<form.Field
name="landingTitle"
children={(field) => (
<Field>
<FieldLabel htmlFor={field.name}>Landing title</FieldLabel>
<Input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
/>The three things you'll use on field constantly are field.state.value (read), field.handleChange(v) (write), and field.state.meta.errors (validation messages). The name is type-checked against defaultValues — typo it and TypeScript complains, which is the payoff for declaring the shape up front.
Validation on submit
Notice the validator key — validators: { onSubmit: schema }. That's deliberate: rules run when you submit, not as you type. Errors don't flash and clear under the user's cursor while they're mid-sentence; they appear once, on the attempt to save, exactly where Zod found a problem.
To actually submit, the native <form> element wires its onSubmit to the form object and prevents the default page reload:
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>form.handleSubmit() validates against the schema first. If it passes, your onSubmit callback runs — and in this panel that means firing the updateSchoolBranding mutation, then closing the panel and toasting on success.
onSubmit: ({ value }) => {
updateSchoolBranding(
{ id: schoolId, body: value },
{
onSuccess: () => {
toastManager.add({ title: 'Branding updated', type: 'success' })
closePanel()
},
},
)
}The form validates, the mutation persists
useAppForm only checks the value is well-formed — it never talks to the network. Persisting the data is the mutation's job, which is why onSuccess and onError live on the updateSchoolBranding call, not on the form. See A feature end to end for how those mutation hooks are built.
Multi-step forms
A wizard like the job application is just several small forms sharing one state object. The trick lives in src/lib/schemas/job-application.schema.ts: each step gets its own Zod schema, and the full schema is composed with .merge().
export const fullJobApplicationSchema = personalStepSchema
.merge(qualificationsStepSchema)
.merge(experienceStepSchema)
.merge(attachmentsStepSchema)Splitting it this way lets you validate one step at a time. A STEP_SCHEMAS map pairs each step number with its schema, so "Next" can check only the fields the user just filled in rather than the whole form.
export const STEP_SCHEMAS = {
1: personalStepSchema,
2: qualificationsStepSchema,
3: experienceStepSchema,
// ...
} as constWhen a step holds a list — like one or more previous roles — validate it with z.array(entry).min(1). The .min(1) is what enforces "add at least one," and the message is what surfaces under the field if the list is empty.
export const experienceStepSchema = z.object({
workHistory: z.array(workHistoryEntrySchema).min(1, 'Add at least one role'),
})That's the entire toolkit. One hook for state, one schema for rules, one mutation to save — whether the form is a five-field panel or a five-step wizard, the parts never change.
Where to go next
Building Year-Scoped Features
The conventions a feature follows on top of the viewing-year system — one hook, year-in-the-key queries, resetKey pagination, and three write gates.
Per-School Theming
How one brand color becomes a full OKLch palette, injected before paint so each school looks like itself.