Naalya Handbook

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.

src/hooks/use-form.tsx
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.

src/components/side-panels/edit-school-branding.panel.tsx
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.

src/components/side-panels/edit-school-branding.panel.tsx
<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:

src/components/side-panels/edit-school-branding.panel.tsx
<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.

src/components/side-panels/edit-school-branding.panel.tsx
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().

src/lib/schemas/job-application.schema.ts
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.

src/lib/schemas/job-application.schema.ts
export const STEP_SCHEMAS = {
  1: personalStepSchema,
  2: qualificationsStepSchema,
  3: experienceStepSchema,
  // ...
} as const

When 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.

src/lib/schemas/job-application.schema.ts
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

On this page