modern-react-spa

Chapter 06

📝 Chapter 6 · Forms Done Right

Forms in modern React — React Hook Form vs TanStack Form vs native <form action>, Zod/Valibot schema validation, optimistic updates, multi-step wizards, and accessibility.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can choose between React Hook Form, TanStack Form, and native <form action> + 19.2 Actions, validate with Zod / Valibot schemas, add optimistic updates with useOptimistic, build multi-step wizards with URL-as-state, and meet the form-accessibility checklist that real users (not just compliance audits) need.

🧭 Prerequisites — Ch 2 (Actions, useOptimistic), Ch 5 (auth example uses the same pattern), Ch 15 (URL state).


🔹 6.1 Three form libraries (and when each fits)

React Hook Form

https://react-hook-form.com — the established default in 2026.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const Schema = z.object({
  email: z.string().email(),
  age:   z.number().int().min(18),
});

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(Schema),
});

<form onSubmit={handleSubmit((data) => save(data))}>
  <input {...register('email')} />
  {errors.email && <p>{errors.email.message}</p>}
</form>

Pros: uncontrolled inputs by default → minimal re-renders; great DX; huge ecosystem. Cons: the register model takes getting used to; controlled-input integrations need adapters.

Pick when: any non-trivial form with multiple fields and validation. The safe default.

TanStack Form

https://tanstack.com/form/latest — newer entrant; typed end-to-end; framework-agnostic core.

import { useForm } from '@tanstack/react-form';

const form = useForm({
  defaultValues: { email: '', age: 18 },
  onSubmit: async ({ value }) => { await save(value); },
});

<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
  <form.Field
    name="email"
    validators={{ onChange: ({ value }) => !value.includes('@') ? 'Invalid email' : undefined }}
    children={(field) => (
      <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
    )}
  />
</form>

Pros: typed defaultValues infer the whole form’s type; pairs with TanStack Query (Ch 14) idiomatically. Cons: smaller community; verbose render-prop API.

Pick when: you’re already on TanStack Query / Router; you want field-level typing.

Native <form action> + React 19.2 Actions

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

const save = async (_prev: State, fd: FormData): Promise<State> => {
  const email = fd.get('email') as string;
  const result = Schema.safeParse({ email });
  if (!result.success) return { error: result.error.flatten().fieldErrors };
  await api.save(result.data);
  return { success: true };
};

const Form = () => {
  const [state, formAction] = useActionState(save, {});
  return (
    <form action={formAction}>
      <input name="email" />
      {state.error?.email && <p>{state.error.email[0]}</p>}
      <SubmitButton />
    </form>
  );
};

const SubmitButton = () => {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
};

Pros: zero library; uses the platform; pairs naturally with useOptimistic. Cons: validation feels coarse compared to RHF/TanStack; per-field error state is manual.

Pick when: simple forms (login, single-field updates, search); pairing with Server Actions in hybrid apps (Ch 24).

🔹 6.2 Zod / Valibot schema-driven validation

Both libraries let you declare the shape once, get types + runtime validation + form errors for free.

Zod

import { z } from 'zod';

const InvoiceSchema = z.object({
  id:     z.string().regex(/^INV-\d+$/),
  amount: z.number().int().positive(),
  status: z.enum(['paid', 'pending', 'overdue']),
  notes:  z.string().max(500).optional(),
});

type Invoice = z.infer<typeof InvoiceSchema>;   // ← derived TS type

Use with React Hook Form via zodResolver. With TanStack Form, pass validators: { onChange: zodValidator(InvoiceSchema) }.

Valibot — Zod’s small competitor

import * as v from 'valibot';

const InvoiceSchema = v.object({
  id:     v.pipe(v.string(), v.regex(/^INV-\d+$/)),
  amount: v.pipe(v.number(), v.integer(), v.minValue(1)),
  status: v.picklist(['paid', 'pending', 'overdue']),
});

Pick Valibot if bundle size matters and your schemas are simple. Pick Zod if you want the larger ecosystem and richer error types. Both produce TS types from the same schema.

Server-side reuse

The schema works on both sides. The same InvoiceSchema runs in:

  • The client form’s validation.
  • The server endpoint’s request validation.
  • The TS type for shared interfaces.

No drift between “what the form accepts” and “what the API expects.”


🔹 6.3 Optimistic updates with useOptimistic

Covered in detail in Ch 2 §2.2. For forms specifically:

const [shown, setShownOptimistic] = useOptimistic(invoice, (current, patch) => ({ ...current, ...patch }));

const updateStatus = async (next: Status) => {
  startTransition(async () => {
    setShownOptimistic({ status: next });    // UI updates immediately
    await api.updateInvoice(invoice.id, { status: next });  // network
    // On reject, React snaps back to `invoice` automatically
  });
};

The pattern shines for status flips, like/unlike toggles, quick edits. Reach for it whenever the network round-trip would otherwise cause a “did it work?” UI gap.


🔹 6.4 Multi-step wizards with URL-as-state

The wizard URL pattern:

/onboarding?step=identity
/onboarding?step=billing
/onboarding?step=team
/onboarding?step=review

Each step’s data accumulates in URL or sessionStorage. The user can refresh, share the URL with support, back-button between steps.

import { useQueryState, parseAsStringEnum } from 'nuqs';

const Steps = ['identity', 'billing', 'team', 'review'] as const;
type Step = typeof Steps[number];

const Wizard = () => {
  const [step, setStep] = useQueryState(
    'step',
    parseAsStringEnum<Step>([...Steps]).withDefault('identity'),
  );

  return {
    identity: <Identity onNext={() => setStep('billing')} />,
    billing:  <Billing  onNext={() => setStep('team')} onBack={() => setStep('identity')} />,
    team:     <Team     onNext={() => setStep('review')} onBack={() => setStep('billing')} />,
    review:   <Review                                  onBack={() => setStep('team')} />,
  }[step];
};

For data accumulation across steps: sessionStorage if you don’t want it bookmarkable; the URL if you do (encoded carefully — don’t shove PII into search params).

const draft = useDraft<OnboardingDraft>('onboarding-draft');
// useDraft is a custom hook backed by sessionStorage with JSON serialisation

🔹 6.5 Accessibility checklist

The minimum every form needs:

  1. Every input has a label. Either <label htmlFor> or aria-label on the input.
  2. Required fields are marked with required (HTML attribute) AND visually (an asterisk + a legend).
  3. Errors are announced via aria-live="polite" on the error region.
  4. Errors are associated with their input via aria-describedby.
  5. Focus moves to the first error on submit failure.
  6. Submit button is reachable by keyboard tab order; not relying on a click handler that requires JS.
  7. Disabled state isn’t the only signal — provide text (“Save (disabled)”) or icon.
  8. Autocomplete uses the standard tokens (autoComplete="email", "current-password", "name").
<form>
  <label htmlFor="email">Email <span aria-hidden="true">*</span></label>
  <input
    id="email"
    name="email"
    type="email"
    autoComplete="email"
    required
    aria-invalid={!!errors.email}
    aria-describedby={errors.email ? 'email-error' : undefined}
  />
  <p id="email-error" aria-live="polite" className="error">
    {errors.email?.message}
  </p>
</form>

The libraries (RHF, TanStack Form) handle most of this when used correctly. shadcn’s form components (Ch 7.5) wire the ARIA defaults automatically.


🪤 Common Pitfalls

  1. Formik in a new project — RHF / TanStack Form / native+Actions cover its use cases, smaller.
  2. Schema duplicated client + server → drift → silent bugs.
  3. useOptimistic without startTransition → no effect; the hook is silent.
  4. Wizard state in useState → reload loses progress; URL or sessionStorage instead.
  5. Required indicator only as a placeholder — placeholders disappear on focus; never the only signal.
  6. Submit button disabled by default until all fields valid → users don’t know why they can’t submit.
  7. Field-level validation on every keystroke → flicker. Validate on blur or submit; show inline errors after the first submit attempt.

✅ Recap

  • Three form approaches; pick by form size and ecosystem.
  • Zod / Valibot — schema once, types and runtime validation.
  • useOptimistic for status-flip / quick-edit UX.
  • Wizards in the URL; data in sessionStorage.
  • The 8-point a11y checklist is mandatory, not optional.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (5)
  1. 6.1 Three form libraries (and when each fits)
  2. 6.2 Zod / Valibot schema-driven validation
  3. 6.3 Optimistic updates with useOptimistic
  4. 6.4 Multi-step wizards with URL-as-state
  5. 6.5 Accessibility checklist