GAP·MAP

React forms

[ middle depth ]

Controlled vs uncontrolled inputs: who owns the value

Every input is one or the other, decided by how you wire it.

  • Controlled: you pass value plus onChange. React owns the value. Each keystroke fires onChange, you call setState, the component re-renders, and React forces the DOM back to exactly value.
  • Uncontrolled: you pass defaultValue (or defaultChecked for a checkbox) or nothing. The DOM owns the value; you read it later from a ref or from the form on submit.
// controlled: React drives the value
const [email, setEmail] = useState("");
<input value={email} onChange={(e) => setEmail(e.target.value)} />

// uncontrolled: the DOM drives it, seeded once at mount
<input name="email" defaultValue="a@b.com" />

Wrong "defaultValue and value are the same thing, one sets a default." value binds the field to state on every render; defaultValue seeds the DOM at mount and is ignored afterward. Right pass value when React must own the field, defaultValue when the DOM should. Mixing them, or switching an input between the two over its life, is the classic bug.

Reading an uncontrolled form on submit

Give every field a name, then let the browser collect them:

function onSubmit(e) {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(e.currentTarget));
}

FormData reads the named fields straight from the DOM, which is why uncontrolled forms need no per-field state. Checkboxes and radios read from e.target.checked, not e.target.value. Duplicate names or a multi-select need fd.getAll(name), which returns an array.

When to reach for a controlled input

Uncontrolled is the shorter, cheaper default: no keystroke re-render, values read on submit. Controlled earns its renders only when you derive UI from the value as the user types: live validation messages, input formatting, enabling or disabling the submit button, or fields that depend on each other. If nothing on screen changes per keystroke, controlled state adds renders and buys nothing.

How React Hook Form wires a native input

React Hook Form keeps inputs uncontrolled and hands you the ergonomics:

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

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("email", { required: "Required" })} />
  {errors.email && <p role="alert">{errors.email.message}</p>}
</form>

register("email") returns { name, onChange, onBlur, ref } that you spread onto the input. The value lives in a ref, so typing updates the library's store without re-rendering the form. handleSubmit runs validation first and calls your handler only when the data is valid.

[ senior depth ]

Why a large controlled form re-renders on every keystroke

A controlled input's onChange calls setState on the component that owns that state, and React re-renders that component plus its whole subtree that is not memoized or passed in as children. One Form holding all fields in a single state object re-renders every field, every validation pass, and all derived UI on each key press. With enough fields it lags.

The fixes, in order:

  • Colocate state so each input owns its own; a keystroke re-renders that field alone.
  • Compose with children or JSX props, which React reuses by reference and skips.
  • React.memo the hot sibling boundaries and stabilize their props.
  • useDeferredValue for an expensive consumer so typing stays responsive.
  • Go uncontrolled, which removes the keystroke re-render outright.

How React Hook Form isolates re-renders

register(name) stores the value in a ref, so typing never re-renders the form. Renders come only from subscriptions: formState is a Proxy, and the library re-renders a component only for the state slices it read. Never read formState.isDirty and a dirtiness change costs no render.

register needs a real DOM ref, and a controlled third-party component (Material UI, react-select, a date picker) has none to give. Wrap it in <Controller> (or useController), which adds a controlled boundary scoped to that one field, and do not also register() the same field.

When to run validation, and with what

React Hook Form defaults to mode: 'onSubmit' with reValidateMode: 'onChange': nothing validates until submit, then a field that already has an error re-checks as the user fixes it. mode also takes onBlur, onChange, onTouched (check on blur, then on change), and all. onTouched is often the best UX: no nagging a pristine field, live feedback once touched.

Three layers combine: native HTML constraints (required, pattern, type="email", plus the interaction-gated :user-invalid selector), RHF's per-field rules, and a schema resolver, useForm({ resolver: zodResolver(schema) }), with z.infer<typeof schema> as the form's type.

Wrong "mode: 'onChange' gives the snappiest experience." With a resolver it re-runs the whole schema on every keystroke and thrashes a large form. Right validate on submit and re-validate on change, or use onTouched.

Submission, server errors, and accessible messages

handleSubmit(onValid, onInvalid) validates first and calls onValid only with clean data; formState.isSubmitting disables the button while the request is in flight. Map a backend field error with setError("email", { type: "server", message }), and setError("root", ...) for a form-level error.

React 19 adds native form actions: <form action={fn}> receives FormData, runs in a Transition, needs no preventDefault, and always POSTs. It auto-resets fields after a successful action, surprising if you expect the values to persist. Return validation and server errors from a useActionState action as state instead of throwing, and read pending state with useFormStatus. Native actions fit simple forms and progressive enhancement; RHF still wins for large forms, field arrays, and controlled UI libraries.

For the message: set aria-invalid={hasError ? "true" : undefined} (never false, which screen readers announce as "invalid: false"), link it with aria-describedby, and keep the role="alert" container mounted while you swap its text so the announcement fires.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more React

was this useful?