React NotesRohit’s interview study guide
Chapter 13

Forms

Controlled forms, validation by hand, React Hook Form, schema validation with Zod, and React 19's form actions — what each approach costs and when to pick it.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Controlled forms

A controlled input is one whose value lives in React state: you pass value={…} and update the state in onChange. React is the single source of truth — the DOM input only ever shows what state says. An uncontrolled input keeps its own value in the DOM, and you read it when you need it (with a ref, or from FormData on submit). The basics, and why a controlled input "can't be typed into" without onChange, are in Controlled vs uncontrolled components; this topic is about whole forms.

Mental model: a controlled form is a puppet — every movement of the input comes from React pulling the strings (state → value), and each keystroke is a request to move ("please set this to ab") that React may accept, change or refuse. An uncontrolled form is a notepad the user writes on freely; you only read the page when they hand it in.

A real form has several fields and several input types. The usual pattern is one state object and one handler keyed by the input's name:

SignupForm.jsx
import { useState } from "react";

const initial = { name: "", email: "", plan: "free", agree: false };

export function SignupForm({ onSubmit }) {
  const [values, setValues] = useState(initial);

  // One handler for every field, keyed by the input's name
  function handleChange(e) {
    const { name, type, value, checked } = e.target;
    setValues((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
  }

  function handleSubmit(e) {
    e.preventDefault(); // stop the browser's full-page form submission
    onSubmit(values);
    setValues(initial); // controlled, so resetting state resets the inputs
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input name="name" value={values.name} onChange={handleChange} />
      </label>
      <label>
        Email
        <input name="email" type="email" value={values.email} onChange={handleChange} />
      </label>
      <label>
        Plan
        <select name="plan" value={values.plan} onChange={handleChange}>
          <option value="free">Free</option>
          <option value="pro">Pro</option>
        </select>
      </label>
      <label>
        <input name="agree" type="checkbox" checked={values.agree} onChange={handleChange} />
        I agree to the terms
      </label>
      <button type="submit" disabled={!values.agree}>
        Sign up
      </button>
      <p>Preview: {values.name || "(no name)"} on {values.plan}</p>
    </form>
  );
}
// Test: type "Rohit", "r@example.com", choose Pro → "Preview: Rohit on pro"
// tick the box, submit → onSubmit({ name: "Rohit", email: "r@example.com", plan: "pro", agree: true })
// afterwards the Name input is empty again
What’s happening
  1. values starts as { name: "", email: "", plan: "free", agree: false }. Each input reads its slice: text inputs and the <select> use value, the checkbox uses checked. The "Sign up" button is disabled because agree is false.
  2. Typing "R" in Name fires onChange. handleChange reads name = "name" and value = "R" from the event target and builds a new object with [name]: value (a computed key), so values.name goes "" → "R". React re-renders and the input shows R. Each keystroke repeats this: the input is only ever showing state.
  3. Choosing "Pro" works the same way — a controlled <select> uses value on the select itself, not selected on an option. The preview paragraph updates live (Preview: Rohit on pro), which is the main thing controlled forms give you: the current values are always in state, ready to render, validate or derive from.
  4. The checkbox's type === "checkbox" branch stores checked (true), not value (which would be the string "on"). Now !values.agree is false and the button enables.
  5. Submitting calls handleSubmit. e.preventDefault() stops the browser's default behaviour (navigating to the form's action URL and reloading the page). It passes the whole values object up, then resets state to initial; because the inputs are controlled, they clear themselves.
  6. The updater form setValues((prev) => …) matters if two fields could change in the same event (autofill fires several change events at once); spreading a stale values would drop one of them.

From my notes, the uncontrolled version of a single field reads the DOM through a ref only when needed:

UncontrolledInput.jsx
import { useRef } from "react";

export function UncontrolledInput({ onSubmit }) {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    onSubmit(inputRef.current.value); // read the DOM only when needed
  };

  return (
    <>
      <input type="text" ref={inputRef} defaultValue="" aria-label="name" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}
// Type "hi", click Submit → onSubmit("hi"); the component rendered once
What’s happening
  1. The input has no value prop, so the browser owns its value. defaultValue sets only the initial value (React won't touch it again).
  2. Typing changes the DOM input directly. React isn't involved: no state update, no re-render.
  3. On click, inputRef.current is the real <input> element, and .value is whatever the user typed ("hi").
  4. The trade-off is visibility: React doesn't know the value until you ask, so you can't render a live preview or disable a button based on it without extra work.
AdvancedWhat controlled costs

Every keystroke in a controlled input is a state update and a re-render of the component that owns the state — and, by the waterfall rule, of everything it renders (see Why components re-render). Typing "hello" into a minimal controlled input rendered its component 6 times (mount + 5). In a 40-field form whose state lives at the top, that's the whole form re-rendering per keystroke. Usually it's still fast enough; when it isn't, the options are splitting fields into memoized components, the React Compiler, or an uncontrolled approach such as React Hook Form, which rendered zero extra times while typing in the test in React Hook Form.

#Validation by hand

Added

Before reaching for a library, it's worth knowing what form validation actually involves, because libraries just automate these pieces:

  1. A rule set: given the values, which fields are wrong and why.
  2. When to show errors: not while the user is still typing their email for the first time, but after they leave the field (blur, tracked as "touched") or try to submit.
  3. Accessibility: mark invalid fields with aria-invalid, link the message with aria-describedby, and move focus to the first problem on submit.
  4. Submit gating: don't call the API with invalid data.

Mental model: a teacher marking an exam. The answer key (the rules) is a pure function; marking happens all the time in the background, but the red pen only comes out for questions the student has finished (touched) or when they hand the paper in (submitted).

ManualValidation.jsx
import { useState } from "react";

// Pure function: values in, errors out. Easy to test and to reuse on the server.
export function validate({ email, password }) {
  const errors = {};
  if (!email) errors.email = "Email is required";
  else if (!/^\S+@\S+\.\S+$/.test(email)) errors.email = "Enter a valid email";
  if (password.length < 8) errors.password = "Password must be at least 8 characters";
  return errors;
}

export function LoginForm({ onSubmit }) {
  const [values, setValues] = useState({ email: "", password: "" });
  const [touched, setTouched] = useState({});
  const [submitted, setSubmitted] = useState(false);

  const errors = validate(values); // derived on every render, never stored
  const show = (field) => (touched[field] || submitted) && errors[field];

  const handleChange = (e) => setValues({ ...values, [e.target.name]: e.target.value });
  const handleBlur = (e) => setTouched({ ...touched, [e.target.name]: true });

  function handleSubmit(e) {
    e.preventDefault();
    setSubmitted(true);
    const firstInvalid = Object.keys(errors)[0];
    if (firstInvalid) {
      e.currentTarget.elements[firstInvalid].focus(); // move the user to the problem
      return;
    }
    onSubmit(values);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        value={values.email}
        onChange={handleChange}
        onBlur={handleBlur}
        aria-invalid={Boolean(show("email"))}
        aria-describedby="email-error"
      />
      {show("email") && <p id="email-error">{errors.email}</p>}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        type="password"
        value={values.password}
        onChange={handleChange}
        onBlur={handleBlur}
        aria-invalid={Boolean(show("password"))}
        aria-describedby="password-error"
      />
      {show("password") && <p id="password-error">{errors.password}</p>}

      <button type="submit">Log in</button>
    </form>
  );
}
// validate({ email: "", password: "" })            → { email: "Email is required", password: "Password must be at least 8 characters" }
// validate({ email: "rohit@", password: "12345678" }) → { email: "Enter a valid email" }
// validate({ email: "r@x.io", password: "12345678" }) → {}
What’s happening
  1. validate is a pure function: values in, an errors object out (an empty object means valid). The test called it directly with the three inputs shown, no React involved. Because it's pure, the same function can run on the server.
  2. errors is derived during render from the current values, not kept in state. Storing it separately would mean keeping two pieces of state in sync, which is where "error message didn't go away" bugs come from (see You might not need an effect).
  3. The user types "rohit@" into Email. errors.email is "Enter a valid email" the whole time, but show("email") is false because the field isn't touched and nothing was submitted. The test confirmed no message while typing.
  4. Tabbing out fires onBlur, touched.email becomes true, and the message appears with aria-invalid="true". The password error stays hidden because that field hasn't been touched. Typing "example.com" fixes the value, errors.email disappears on the next render, and so does the message — instantly, because errors are derived.
  5. Clicking "Log in" with an empty password sets submitted, which reveals all errors, and focuses the first invalid field through form.elements[name] — the test saw focus on password. onSubmit isn't called. After typing a valid password, submit calls onSubmit({ email: "rohit@example.com", password: "correct-horse" }).
  6. noValidate turns off the browser's own validation bubbles (from type="email", required and so on) so the app's messages are the only ones. Without it, the browser would block submit and show its own tooltip first.

#React Hook Form

My notes have this as a heading with a note to myself — "Read it man… It's IMPORTANT!!!" — and nothing under it (any screenshots didn't survive), so this topic is written from scratch. It deserves the emphasis: React Hook Form (RHF, version 7) is the most widely used form library for React, and interviewers ask about it because its design is the opposite of "put every field in state".

The core idea: RHF uses uncontrolled inputs and refs. register("email") returns { name, onChange, onBlur, ref }; you spread that onto a native input. RHF keeps the values in its own store (outside React state), reads the DOM through the ref, runs validation when you tell it to, and only triggers a React re-render when something you actually render changes — like an error appearing or isSubmitting flipping. Typing into a field doesn't re-render your component at all.

Mental model: RHF is a clerk sitting beside the form with a clipboard. The user writes straight onto the paper (the DOM); the clerk glances at it when needed (validation, submit) and only taps you on the shoulder (a re-render) when there's news — "field 2 has an error now", "submitting…".

RhfSignup.jsx
import { useForm } from "react-hook-form";

export function RhfSignup({ onSave }) {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm({ defaultValues: { name: "", email: "", age: "" } });

  async function onSubmit(data) {
    await onSave(data); // isSubmitting stays true until this promise settles
    reset(); // back to defaultValues
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="name">Name</label>
      <input id="name" {...register("name", { required: "Name is required" })} />
      {errors.name && <p role="alert">{errors.name.message}</p>}

      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        {...register("email", {
          required: "Email is required",
          pattern: { value: /^\S+@\S+\.\S+$/, message: "Enter a valid email" },
        })}
      />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <label htmlFor="age">Age</label>
      <input
        id="age"
        type="number"
        {...register("age", {
          valueAsNumber: true,
          required: "Age is required",
          min: { value: 18, message: "You must be 18 or older" },
          validate: (v) => Number.isInteger(v) || "Age must be a whole number",
        })}
      />
      {errors.age && <p role="alert">{errors.age.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving…" : "Save"}
      </button>
    </form>
  );
}
// Test (with a render counter in the component):
// renders after mount: 2; after typing 11 characters into Name and Email: still 2
// submit → alerts: "Enter a valid email", "Age is required"; focus moves to Email; renders: 4
// type "example.com" → the email alert disappears while typing
// age "16" + submit → "You must be 18 or older"; age "40" + submit → button "Saving…" (disabled)
// onSave received { name: "Rohit", email: "rohit@example.com", age: 40 }; then the form resets
What’s happening
  1. useForm({ defaultValues }) creates the form's store. Always give defaultValues: RHF uses them for the initial DOM values, for reset(), and for working out which fields are "dirty".
  2. {...register("name", { required: "Name is required" })} spreads name, onChange, onBlur and a callback ref onto the input (the test printed exactly those four keys). The ref lets RHF read and write the DOM input directly. The second argument holds the rules: required, min/max, minLength/maxLength, pattern, and validate (your own function returning true or an error message).
  3. Typing "Rohit" and "rohit@" (11 keystrokes) re-rendered the component zero times — the counter stayed at 2 (the extra mount render is RHF settling its form state). Compare with the 6 renders for one controlled input typing "hello" in Controlled forms.
  4. Clicking Save runs handleSubmit: it calls e.preventDefault(), validates every field, and because some fail, fills errors and focuses the first invalid field (Email) instead of calling onSubmit. That's the default mode: "onSubmit": nothing is validated until the first submit.
  5. After a failed submit, the default reValidateMode: "onChange" kicks in: typing "example.com" re-validated Email on each change and the alert vanished as soon as it was valid. This is the "quiet until submit, then helpful" behaviour from Validation by hand, without writing it.
  6. valueAsNumber: true makes RHF hand you 40, not "40". An empty number input becomes NaN, which is why required is there: without it, the empty field failed the Number.isInteger check with the less helpful "Age must be a whole number" (also tested).
  7. With valid data, handleSubmit calls onSubmit(data). Because it returns a promise, isSubmitting is true until it settles — the button showed "Saving…" and was disabled, which prevents double submits. Then reset() restored the defaultValues and the inputs emptied.

Not every input is a native element you can attach a ref to. For controlled components — a date picker, a MUI Select, a custom star rating — RHF has Controller, which hands you field.value / field.onChange to wire up yourself:

RatingField.jsx
import { Controller, useForm } from "react-hook-form";

// A controlled component with no ref and no native input: it only knows value/onChange
function StarRating({ value, onChange, onBlur }) {
  return (
    <div role="radiogroup" aria-label="Rating" onBlur={onBlur}>
      {[1, 2, 3, 4, 5].map((n) => (
        <button
          key={n}
          type="button"
          role="radio"
          aria-checked={value === n}
          onClick={() => onChange(n)}
        >
          {n <= value ? "★" : "☆"}
        </button>
      ))}
    </div>
  );
}

export function ReviewForm({ onSubmit }) {
  const { control, register, handleSubmit, formState: { errors } } = useForm({
    defaultValues: { rating: 0, comment: "" },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="rating"
        control={control}
        rules={{ min: { value: 1, message: "Pick a rating" } }}
        render={({ field }) => (
          <StarRating value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
        )}
      />
      {errors.rating && <p role="alert">{errors.rating.message}</p>}
      <textarea aria-label="Comment" {...register("comment")} />
      <button type="submit">Send</button>
    </form>
  );
}
// Submit with no stars → "Pick a rating"; click the 4th star → ★★★★☆, alert gone
// type "Great", submit → onSubmit({ rating: 4, comment: "Great" })
What’s happening
  1. StarRating has no <input> and no ref — it's purely controlled (value in, onChange(n) out). register can't work with it, because there's no DOM value for RHF to read.
  2. <Controller name="rating" control={control} …> registers the field with the form's store and calls render with a field object: value, onChange, onBlur, name and a ref. You connect those to the component's own props.
  3. Submitting with rating still 0 fails min: 1: the alert "Pick a rating" appears.
  4. Clicking the fourth star calls field.onChange(4). RHF stores 4, re-validates (we're past the first submit), clears the error and re-renders the Controller so StarRating shows ★★★★☆.
  5. register and Controller mix freely in one form: the native <textarea> uses register, and the submit data is { rating: 4, comment: "Great" }. Use Controller (or the useController hook) only for the controlled widgets — it re-renders on every change, like any controlled input.
AdvancedDynamic rows with useFieldArray, and watching values with useWatch
FieldArray.jsx
import { useFieldArray, useForm, useWatch } from "react-hook-form";

function Total({ control }) {
  const items = useWatch({ control, name: "items" }); // subscribes only this component
  const sum = items.reduce((acc, item) => acc + (Number(item.qty) || 0) * (Number(item.price) || 0), 0);
  return <p>Total: {sum.toFixed(2)}</p>;
}

export function InvoiceForm({ onSubmit }) {
  const { control, register, handleSubmit } = useForm({
    defaultValues: { items: [{ name: "Coffee", qty: 1, price: 4.5 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <fieldset key={field.id}>
          <input aria-label={`Item ${index + 1} name`} {...register(`items.${index}.name`)} />
          <input aria-label={`Item ${index + 1} qty`} type="number" {...register(`items.${index}.qty`, { valueAsNumber: true })} />
          <input aria-label={`Item ${index + 1} price`} type="number" step="0.01" {...register(`items.${index}.price`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>Remove {index + 1}</button>
        </fieldset>
      ))}
      <button type="button" onClick={() => append({ name: "", qty: 1, price: 0 })}>Add item</button>
      <Total control={control} />
      <button type="submit">Save</button>
    </form>
  );
}
// Mount: "Total: 4.50". Add item, name "Cake", qty 2, type price "3.25" → "Total: 11.00"
// While typing the price: InvoiceForm re-rendered 0 times, Total 3 times
// Remove item 1 → "Total: 6.50"; submit → {"items":[{"name":"Cake","qty":2,"price":3.25}]}
What’s happening
  1. useFieldArray({ control, name: "items" }) manages a list of field groups. fields is the array to render, and each entry has a generated field.id — use that as the key, not the index, so React keeps the right DOM inputs when rows are removed or reordered (see Rendering lists and keys).
  2. Field names use dot paths: items.0.price, items.1.price. RHF builds the nested { items: [...] } object from them on submit.
  3. append and remove change the rows; the form component re-renders for those (the row structure changed), which is expected.
  4. Total uses useWatch, which subscribes only Total to the items values. Typing "3.25" re-rendered Total 3 times and InvoiceForm 0 times. The totals seen were 10.5 ("3"), then 10.9 ("3.2"), then 11 ("3.25") — the "3." keystroke didn't change the number, so useWatch skipped that render.
  5. The alternative watch("items") called in InvoiceForm would re-render the whole form on every keystroke. Prefer useWatch in a small child for live derived values.

What RHF gives you over hand-rolled forms: very few re-renders, validation timing modes (onSubmit, onBlur, onChange, onTouched, all), focus management, isDirty/dirtyFields/touchedFields, async validation, setError for server-side errors, reset with new values (for edit forms loaded from an API, or the values option to keep in sync), field arrays, and FormProvider/useFormContext for splitting a big form across components. And it's small (useForm built with Vite came to about 11 kB gzipped, React excluded) with no dependencies.

#Schema validation with Zod

Added

Writing rules field by field gets repetitive, and the rules end up in the form instead of somewhere the server can share. A schema library describes the shape of valid data once — "an object with a non-empty name, a valid email, an integer age ≥ 18" — and gives you a parser that either returns clean, typed data or a list of issues. Zod (version 4) is the most common choice in React and TypeScript projects. With TypeScript it also generates the type from the schema, so the form's types and its validation can never disagree.

Mental model: a schema is a customs checkpoint for data. Everything coming in (form input, API responses, URL params) is inspected against the declaration; what passes is guaranteed to have the declared shape (and is often converted, like "40" → 40), and what fails comes back with a list of exactly what was wrong and where.

signupSchema.js
import * as z from "zod";

export const signupSchema = z
  .object({
    name: z.string().trim().min(1, "Name is required"),
    email: z.email("Enter a valid email"),
    age: z.coerce.number().int("Age must be a whole number").min(18, "You must be 18 or older"),
    password: z.string().min(8, "Password must be at least 8 characters"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: "Passwords don't match",
    path: ["confirmPassword"], // attach the error to this field
  });

// signupSchema.safeParse({ name: " Rohit ", email: "rohit@example.com", age: "40",
//                          password: "correct-horse", confirmPassword: "correct-horse" })
// → { success: true, data: { name: "Rohit", email: "rohit@example.com", age: 40, password: "correct-horse", confirmPassword: "correct-horse" } }
//
// signupSchema.safeParse({ name: "R", email: "r@x.io", age: "40", password: "correct-horse", confirmPassword: "nope" })
// → z.flattenError(result.error) = { formErrors: [], fieldErrors: { confirmPassword: ["Passwords don't match"] } }
What’s happening
  1. z.object({...}) declares the shape. Each field is a schema: z.string(), z.email(), z.coerce.number(). Chained methods add checks (.min(1), .int()) or transforms (.trim()), and the string argument is the error message.
  2. safeParse never throws: it returns { success: true, data } or { success: false, error }. In the passing case, name came back trimmed (" Rohit " → "Rohit") and age came back as the number 40 (from the string "40", because z.coerce.number() runs Number(value) first). The output is cleaned data, not just a yes/no.
  3. .refine(fn, { error, path }) adds a rule about the whole object. Password confirmation can't live on either field alone, because it compares two. path: ["confirmPassword"] puts the issue on that field, so a form can show it under the right input.
  4. z.flattenError(error) turns the issue list into { formErrors, fieldErrors } — the second is keyed by field name, exactly what a form needs. z.prettifyError(error) gives a readable multi-line string for logs (the test printed lines like "✖ Enter a valid email → at email").
  5. Zod 4 changes from v3 you'll see in older code: format checks are top-level (z.email(); z.string().email() still works but is deprecated), messages go in error (the v3 message key is deprecated), and z.flattenError(err) replaces err.flatten(). The sandbox ran Zod 4.6.5.

Plugging a schema into React Hook Form takes one line with @hookform/resolvers. The resolver replaces RHF's built-in rules: on validation, RHF hands the values to Zod and maps the issues back to errors.

ZodSignup.jsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema } from "./signupSchema.js";

const fields = [
  ["name", "Name", "text"],
  ["email", "Email", "email"],
  ["age", "Age", "number"],
  ["password", "Password", "password"],
  ["confirmPassword", "Confirm password", "password"],
];

export function ZodSignup({ onSave }) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(signupSchema),
    defaultValues: { name: "", email: "", age: "", password: "", confirmPassword: "" },
  });

  return (
    <form onSubmit={handleSubmit(onSave)} noValidate>
      {fields.map(([name, label, type]) => (
        <div key={name}>
          <label htmlFor={name}>{label}</label>
          <input id={name} type={type} {...register(name)} />
          {errors[name] && <p role="alert">{errors[name].message}</p>}
        </div>
      ))}
      <button type="submit">Create account</button>
    </form>
  );
}
// Empty submit → "Name is required", "Enter a valid email", "You must be 18 or older",
//                "Password must be at least 8 characters"
// Valid fields but confirm "correct-hors" → "Passwords don't match"
// Fix it → onSave({ name: "Rohit", email: "rohit@example.com", age: 40, password: "correct-horse", confirmPassword: "correct-horse" })
What’s happening
  1. zodResolver(signupSchema) adapts the schema to RHF's resolver interface. register(name) now has no rules — the schema is the only source of them.
  2. On the empty submit, RHF passes all values to Zod. The issues come back keyed by path and land in errors.name, errors.email and so on, with the schema's messages.
  3. Note the age message: the empty string was coerced to 0 (Number("") is 0), so the error is "You must be 18 or older", not "required". That's a z.coerce gotcha (below).
  4. With five valid fields except a mismatched confirmation, only the refine fails, and its path puts "Passwords don't match" under Confirm password.
  5. Once valid, onSave receives Zod's output: age is the number 40, not the string from the input. The schema validated and converted, so onSave never deals with raw form strings.

With TypeScript, the schema is also the type. z.input is what the form holds and z.output (same as z.infer) is what the submit handler receives; the resolver makes useForm infer both:

TypedForm.tsx
import { useForm, type SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({
  name: z.string().min(1),
  age: z.coerce.number().int().min(18),
});

type FormInput = z.input<typeof schema>;   // { name: string; age: unknown }
type FormOutput = z.output<typeof schema>; // { name: string; age: number }  (z.infer is the same as z.output)

export function TypedForm() {
  const { register, handleSubmit } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { name: "", age: "" },
  });
  const onSubmit: SubmitHandler<FormOutput> = (data) => {
    const n: number = data.age; // ✅ the handler receives the parsed output
    console.log(n);
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      <input {...register("age")} />
      <input {...register("nmae")} /> {/* ❌ TS2345: Argument of type '"nmae"' is not assignable to parameter of type '"age" | "name"'. */}
    </form>
  );
}
What’s happening
  1. z.coerce.number() accepts anything as input (it will try Number(x)), so z.input types age as unknown; after parsing, z.output types it as number. The test verified both with type-level equality checks.
  2. useForm({ resolver: zodResolver(schema) }) picks up both types from the resolver: field names, defaultValues and register use the input type; handleSubmit's callback gets the output type.
  3. So data.age is number in the handler, with no casts, and a typo like register("nmae") is a compile error — the message shown is the real one from tsc (TypeScript 7 in the sandbox).
  4. One schema now drives the runtime validation, the TypeScript types and (imported on the server) the API's validation.

#Forms with React 19 actions

Added

React 19 made the native <form> a first-class React feature. Pass a function to action and React calls it with the form's FormData when the form is submitted, inside a transition. useActionState keeps what the action returns (errors, a success message, the submitted values), and useFormStatus lets a nested button know the form is pending. The inputs are uncontrolled — no value, no onChange, no state per field. This works in plain client apps, and in frameworks the same action can be a Server Function ("use server"), so the form even works before JavaScript loads. The hooks are covered in depth in useActionState and Form actions and useFormStatus; this topic is about building a validated form with them.

Mental model: back to how HTML forms always worked — fill in the paper form, hand it over the counter, get a reply slip back. The action is the clerk behind the counter; the reply slip is the action's return value, and React renders it.

ActionSignup.jsx
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import * as z from "zod";

const schema = z.object({
  name: z.string().trim().min(1, "Name is required"),
  email: z.email("Enter a valid email"),
});

// In a real app this could be a Server Function ("use server") — the same shape works.
export async function signup(prevState, formData) {
  const values = Object.fromEntries(formData); // { name: "...", email: "..." }
  const result = schema.safeParse(values);
  if (!result.success) {
    return { ok: false, values, errors: z.flattenError(result.error).fieldErrors };
  }
  await new Promise((r) => setTimeout(r, 50)); // pretend to save
  return { ok: true, values: { name: "", email: "" }, errors: {}, message: `Welcome, ${result.data.name}!` };
}

function SubmitButton() {
  const { pending } = useFormStatus(); // reads the nearest parent <form>'s status
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Signing up…" : "Sign up"}
    </button>
  );
}

const initialState = { ok: false, values: { name: "", email: "" }, errors: {} };

export function ActionSignup() {
  const [state, formAction] = useActionState(signup, initialState);

  return (
    <form action={formAction} noValidate>
      <label htmlFor="name">Name</label>
      {/* React resets the form after the action; defaultValue puts the submitted text back */}
      <input id="name" name="name" defaultValue={state.values.name} />
      {state.errors.name && <p role="alert">{state.errors.name[0]}</p>}

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" defaultValue={state.values.email} />
      {state.errors.email && <p role="alert">{state.errors.email[0]}</p>}

      <SubmitButton />
      {state.message && <p role="status">{state.message}</p>}
    </form>
  );
}
// Submit "Rohit" + "rohit@" → alert "Enter a valid email"; inputs still show "Rohit" and "rohit@"
// Fix the email and submit → button "Signing up…" (disabled) → "Welcome, Rohit!"; inputs empty
What’s happening
  1. useActionState(signup, initialState) returns the current state (starting as initialState) and formAction, a wrapped version of signup. Passing formAction to <form action> makes React intercept the submit — no preventDefault, no onSubmit.
  2. On submit, React builds a FormData from the inputs' name attributes and calls signup(prevState, formData). Object.fromEntries(formData) turns it into { name: "Rohit", email: "rohit@" }, which Zod checks. It fails, so the action returns the errors and the submitted values; that return value becomes the new state and React re-renders with the alert.
  3. After an action finishes, React automatically resets the form — uncontrolled inputs go back to their default values. The test proved this on a minimal form: the action received "hello" and the input was "" afterwards. That's why the inputs use defaultValue={state.values.…}: the reset restores the values the action returned, so the user's text survives a failed submit (the test saw "Rohit" and "rohit@" still there).
  4. Fixing the email and submitting again: while the async action runs, SubmitButton's useFormStatus() reports pending: true, so it shows "Signing up…" and is disabled. useFormStatus must be called in a component inside the <form>; it reads that form's status, which is why the button is its own component.
  5. On success the action returns empty values and a message. React resets the form to the new defaults (empty) and renders "Welcome, Rohit!". The whole form has no useState and no change handlers.
AdvancedCombining React Hook Form with an action

RHF and actions aren't rivals: RHF gives instant client-side validation and focus handling; an action gives pending state and a place for the server's answer (like "email already taken"). Let RHF validate, and dispatch the action with the clean data:

RhfWithAction.jsx
import { startTransition, useActionState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({ email: z.email("Enter a valid email") });

async function subscribe(prevState, data) {
  await new Promise((r) => setTimeout(r, 50)); // pretend to call the server
  return data.email.endsWith("@taken.com")
    ? { error: "That email is already subscribed" }
    : { message: `Subscribed ${data.email}` };
}

export function NewsletterForm() {
  const [state, dispatch, isPending] = useActionState(subscribe, {});
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { email: "" },
  });

  // RHF validates on the client first; only valid data reaches the action
  const onValid = (data) => startTransition(() => dispatch(data));

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" {...register("email")} />
      {errors.email && <p role="alert">{errors.email.message}</p>}
      {state.error && <p role="alert">{state.error}</p>}
      {state.message && <p role="status">{state.message}</p>}
      <button disabled={isPending}>{isPending ? "Subscribing…" : "Subscribe"}</button>
    </form>
  );
}
// "rohit@" → client error "Enter a valid email" (the action never runs)
// "rohit@taken.com" → "Subscribing…" → "That email is already subscribed"
// "rohit@example.com" → "Subscribed rohit@example.com"; the input keeps its text
What’s happening
  1. The form uses onSubmit={handleSubmit(onValid)}, not action, so RHF controls submission: invalid data never leaves the client ("Enter a valid email" with no action call).
  2. onValid receives the parsed data and calls dispatch(data) inside startTransition. Calling an action-state dispatcher outside a form action must happen in a transition — that's what makes isPending work and keeps React from warning.
  3. isPending (the third value of useActionState) drives the button, because useFormStatus only tracks forms submitted through action, not onSubmit.
  4. The action returns a server-style error for @taken.com, which renders as a second alert. Because there was no form action, React didn't reset the form: the input still held rohit@example.com after success. Call RHF's reset() if you want it cleared.

#Choosing a form approach

Added

There's no single right answer; the choice depends on how big the form is, how live the feedback must be, and whether the app uses a server framework. A summary of this chapter:

ApproachValues live inRe-renders while typingValidationBest for
Controlled + useStateReact stateevery keystrokehand-writtensmall forms; live previews; values that drive other UI
Uncontrolled + ref / FormDatathe DOMnoneHTML attributes or on submitvery simple forms, search boxes, file inputs
React Hook Form (+ Zod)RHF's store and the DOMnone (only for errors/status)rules or a schema, with timing modesmost real app forms: large, dynamic, many rules
React 19 action + useActionStatethe DOM, FormData on submitnonein the action (often on the server)framework apps with Server Functions; progressive enhancement
Formik (older)React stateevery keystrokeYup schemasexisting codebases; not recommended for new code

Some rules of thumb:

  • Start simple. A login form with two fields doesn't need a library; controlled inputs or a plain action are fine.
  • Reach for React Hook Form once you have more than a handful of fields, field arrays, multi-step wizards, or async validation. Pair it with Zod so rules are declared once and shared with the server.
  • Use actions for the submit itself in React 19 apps, especially with Next.js Server Functions: pending state, server errors and progressive enhancement come for free. They combine with RHF as shown above.
  • Always validate on the server too, whichever client approach you pick.
  • Accessibility is part of the job: real <label>s, aria-invalid and aria-describedby on errors, focus the first invalid field, and don't disable the submit button as your only error signal (users can't tell why it's disabled).
How many times does the form render?

A form has 20 text fields registered with React Hook Form (useForm with default options), and the component destructures formState: { errors }. The user types a 10-character name into one field, then clicks Submit and every field is valid. Roughly how many times does the form component render after mount?

Show answer

Once. Zero renders while typing, and one for the successful submit.

What’s happening
  1. The inputs are uncontrolled and registered through refs. Typing changes the DOM and RHF's internal store, but no React state — so the 10 keystrokes cause no renders.
  2. Default mode: "onSubmit" means nothing is validated while typing, so errors doesn't change either.
  3. On submit, handleSubmit validates and updates form state (isSubmitted, submitCount), and the component renders for it. I ran exactly this form: 2 renders after mount, still 2 after typing 10 characters, 3 after a valid submit. A failed submit costs one more, because errors (which the component reads) changes too — the signup test in React Hook Form went from 2 to 4.
  4. The same form built with 20 controlled useState fields would render 10 times while typing, and re-render all 20 inputs each time.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.