Form conventions

The unified TanStack Form + Zod + shadcn Field pattern, with a minimal copyable example

Last updated October 14, 2026


Every form in this repo uses TanStack Form + Zod `validators` + shadcn `Field`. Do not hand-roll useState for errors, and do not write your own input controls.

Minimal example

tsx
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
import { AuthFormField } from '@/components/auth/AuthFormField'
import { SubmitButton } from '@/components/auth/SubmitButton'

const schema = z.object({ email: z.email() })

function ExampleForm() {
  const form = useForm({
    defaultValues: { email: '' },
    validators: { onSubmit: schema },
    onSubmit: async ({ value }) => {
      // Call the backend; render server errors as shown below.
      await save(value)
    },
  })

  return (
    <form
      noValidate
      onSubmit={(e: React.SubmitEvent<HTMLFormElement>) => {
        e.preventDefault()
        void form.handleSubmit()
      }}
    >
      <form.Field name="email">
        {(field) => <AuthFormField field={field} label="Email" type="email" />}
      </form.Field>
      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => <SubmitButton pending={isSubmitting}>Save</SubmitButton>}
      </form.Subscribe>
    </form>
  )
}

Three rules you must follow

1. `<form>` must set `noValidate`

<Input type="email"> triggers native constraint validation. On an invalid value the browser blocks the submit event outright, so onSubmit never runs — and neither does TanStack Form's schema validation nor its error messages. Validation belongs to the schema, so noValidate is not optional.

2. Submit buttons must be disabled before hydration

SubmitButton is disabled until hydration (see useHydrated). Without that defence, a click landing before React takes over degrades into a native GET submission, writing the fields (password included) into the URL, browser history, and the Referer header. Supply pending from form.Subscribe's isSubmitting rather than a separate useState.

3. Validation messages are injected by the schema factory

The schema factories in src/auth/validation.ts take the messages as an argument (AuthFormMessages) instead of calling m.*() themselves. That keeps validation free of the Paraglide runtime and unit-testable without components; pages evaluate authFormMessages(), so error messages follow the language automatically.

ts
export function createLoginSchema(msg: AuthFormMessages) {
  return z.object({ email: emailField(msg), password: passwordField(msg) })
}

Each field uses superRefine to emit exactly one error: chained .min().regex() would report both "required" and "invalid format" for the same empty value, and the user only needs the first.

Server errors

Field values, field-level errors, and submit state all live in TanStack Form. A page-level message (such as "incorrect email or password") stays in a component useState: TanStack Form 1.33 offers no type-safe way to set an arbitrary page-level error (errorMap.onServer requires declaring an onServer validator before it is writable).

tsx
const [serverError, setServerError] = useState<string | null>(null)

const form = useForm({
  // ...
  onSubmit: async ({ value }) => {
    setServerError(null)
    const { error } = await signIn.email(value)
    if (error) setServerError(error.message?.split('\n')[0] ?? m.auth_error_generic())
  },
})

// rendering
{serverError && <p role="alert" className="text-sm text-destructive">{serverError}</p>}

Field-level errors are wired to aria-invalid and aria-describedby automatically by AuthFormField — no hand-written wiring.

Where feedback goes: toast or in-page

A message in the wrong place is worse than no message: an "already subscribed" line at the bottom of the page is outside the viewport right after the click that triggered it, so the user never sees it. Feedback is split by where the user is looking:

CaseWhere it goesWhy
Successful action (save, update, send, delete)toastThe result may be half a screen away from the control
Checkout outcome (success / cancel / duplicate)toastThe page is rebuilt after the redirect; there is no anchor for an inline line
Failed form submissionin-form <p role="alert">The user has to go back and edit; the clue must stay in place
Field validationunder the fieldWired by AuthFormField
Failure inside a dialoginside the dialogA toast sits behind the overlay and the focus trap
List load failurein pageThat is page state, not the result of an action

<Toaster /> is mounted once in src/routes/__root.tsx; business code only calls the singleton:

tsx
import { toast } from '@/components/ui/toast'

toast.add({ type: 'success', title: m.settings_profile_name_success() })
  • type picks the icon: success / info / warning / error / loading.
  • Use description for a second line. Toasts dismiss after 5 seconds by default; pass an

explicit timeout when the copy needs longer.

  • One-shot messages driven by a ?query parameter (Stripe checkout results, for example)

must be followed by a replace navigation that clears the parameter — otherwise every reload re-shows a message that was already handled.

Where the components come from

Input controls, labels, and error display all come from src/components/ui (the shadcn Base UI variant). Do not modify those files locally — that keeps them updatable from upstream. Add new controls with the CLI:

bash
pnpm dlx shadcn@latest add <component> -y

AuthFormField is a thin adapter between TanStack Form and Field: it only wires things up and carries no styling of its own.

What's next