Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I11
22 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I11 · Part 2 · Intermediate

Forms at scale

Where hand-rolled state stops paying, and what a form library actually buys you.

3 min read9 sections2 exercises
22 of 57

In this chapter

  • The point where it stops working
  • React Hook Form, and why it is fast
  • Schema validation
  • Dynamic field arrays
  • Mapping server errors back onto fields

and 4 more

about 3 min
09On this page
  1. The point where it stops working
  2. React Hook Form, and why it is fast
  3. Schema validation
  4. Dynamic field arrays
  5. Mapping server errors back onto fields
  6. Accessibility, which is most of the remaining work
  7. Multi-step forms
  8. React 19 form actions
  9. Choosing

The point where it stops working

A login form needs no library. The trouble starts at a specific set of requirements, and it is worth naming them so you can tell which one you have:

  • Twenty fields, so every keystroke re-renders all twenty.
  • Repeated groups — add a line item, remove one, reorder.
  • Rules that span fields: end date after start date, total must match.
  • Async validation, like checking a username is free, debounced.
  • Multi-step wizards with per-step validation and a shared draft.
  • Server errors that need mapping back onto individual fields.

React Hook Form, and why it is fast

const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();

<form onSubmit={handleSubmit(onValid)}>
  <input {...register("email", { required: "Email is required" })} />
  {errors.email && <p>{errors.email.message}</p>}
  <button disabled={isSubmitting}>Save</button>
</form>

register returns name, onChange, onBlur and a ref. The inputs are uncontrolled — values live in the DOM and the library reads them through refs, so typing does not re-render the form at all. Only the components that subscribe to a specific error re-render.

That is the whole performance story, and it is why the library exists. Controlled forms re-render on every keystroke; at twenty fields with any derived work, that becomes visible.

Schema validation

const schema = z.object({
  email: z.string().email("Not a valid email"),
  age: z.coerce.number().min(18, "Must be 18 or over"),
  password: z.string().min(8),
  confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
  message: "Passwords do not match",
  path: ["confirm"],                       // attach the error to a field
});

const { register, handleSubmit } = useForm({ resolver: zodResolver(schema) });

The rules move out of the JSX into one object, which means they can be reused — the same schema validates on the client, on the server, and produces the TypeScript type:

type FormValues = z.infer<typeof schema>;   // one source of truth
Client validation is UX, not security

Anything the browser checks can be bypassed with one curl. The server must validate the same rules independently — sharing the schema is what makes that cheap rather than a second implementation that drifts.

Dynamic field arrays

const { fields, append, remove } = useFieldArray({ control, name: "items" });

{fields.map((field, index) => (
  <div key={field.id}>                     // field.id, never index
    <input {...register(`items.${index}.name`)} />
    <button type="button" onClick={() => remove(index)}>Remove</button>
  </div>
))}
<button type="button" onClick={() => append({ name: "" })}>Add</button>

This is the index-key bug in its most expensive form: remove the first row with index keys and every row below keeps the previous row's typed value. The library generates a stable field.id precisely for this.

Mapping server errors back onto fields

try {
  await save(values);
} catch (err) {
  if (err.fields) {
    for (const [name, message] of Object.entries(err.fields)) {
      setError(name, { message });          // lands on the right input
    }
  } else {
    setError("root", { message: err.message });   // form-level
  }
}

"Email already registered" belongs next to the email field, not in a banner at the top. This is the step most hand-rolled forms skip, and it is the difference between a form people can fix and one they abandon.

Accessibility, which is most of the remaining work

<label htmlFor="email">Email</label>
<input
  id="email"
  {...register("email")}
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
  autoComplete="email"
/>
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
  • Every input has a real label. A placeholder is not one.
  • aria-describedby ties the error to the field, so it is announced with it.
  • Move focus to the first error on submit — otherwise a keyboard user has no idea what failed or where.
  • autoComplete is the difference between a form the browser fills and one it does not.

Multi-step forms

Keep one state object for the whole wizard, held above the steps, and validate per step against a slice of the schema. Put the step number in the URL so refresh and back work, and persist the draft — a wizard that loses everything on an accidental refresh is the most annoying form there is.

React 19 form actions

function Signup() {
  const [state, formAction, isPending] = useActionState(signup, null);
  return (
    <form action={formAction}>
      <input name="email" />
      <button disabled={isPending}>Sign up</button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

A <form action={fn}> gets the pending state, the result and progressive enhancement built in — and it works before hydration. useFormStatus lets a nested submit button read the pending state without prop drilling. For simple forms this removes the library entirely; Server Actions takes it further.

Choosing

FormReach for
Login, search, a few fieldsuseState, or a form action
10+ fields, or cross-field rulesReact Hook Form + Zod
Dynamic rows, wizards, draftsReact Hook Form, definitely
Framework with server actionsActions first; add a library only for the client-side niceties
Interview answer, one sentence

"Hand-rolled controlled forms re-render everything on every keystroke and make you reimplement validation, arrays and error mapping. React Hook Form keeps values in the DOM and subscribes narrowly, and a Zod schema gives you client validation, server validation and the TypeScript type from one definition."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsValidation issues to field errorsintermediate
  • 9 testsA three-step form with validation per stepintermediate
All 66 for this level →
I11

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I10Previous · I10Data fetching patterns
  2. I11you are here
  3. Next · I12State management librariesWhat a store gives you that context cannot: selectors.5 min read

01On this page

9 sections · 0%

  1. 01The point where it stops working
  2. 02React Hook Form, and why it is fast
  3. 03Schema validation
  4. 04Dynamic field arrays
  5. 05Mapping server errors back onto fields
  6. 06Accessibility, which is most of the remaining work
  7. 07Multi-step forms
  8. 08React 19 form actions
  9. 09Choosing

[] chapters · t top