A controlled input gets its value from React state and reports every change through onChange, so state is the single source of truth. An uncontrolled input keeps its own value in the DOM; you give it a defaultValue and read it when you need it, through a ref or FormData.
Controlled: use it when the UI reacts to every keystroke: live validation, formatting a phone number, disabling the button until the form is valid, fields that depend on each other.
Uncontrolled: less code and no re-render per keystroke; enough when you only need the values on submit. React 19 form actions and React Hook Form both lean on it.
The warning: value without onChange makes a read-only field, and React warns about it. Use defaultValue if you only want an initial value.
Do not mix: switching one input between the two modes (for example value going from undefined to a string) triggers the "changing an uncontrolled input to be controlled" warning.
ReactMiddle
Forms & Actions
Controlled and uncontrolled inputs, React Hook Form, schema validation with Zod, React 19 actions, useActionState, useFormStatus, useOptimistic
Questions closed
0 / 21
- 0Review
- 5Asked often
Questions
Give every field a name, handle onSubmit, call e.preventDefault() and build new FormData(e.currentTarget). The browser already holds the values; FormData collects them, including checkboxes, selects and files.
Object.fromEntries(formData) turns it into a plain object; use formData.getAll('tags') for fields with several values.
Every value is a string (or a File), so numbers and booleans need converting, ideally by a schema such as Zod.
An unchecked checkbox is simply absent from FormData.
In React 19 you can skip preventDefault: pass a function to <form action={fn}> and React calls it with the FormData for you.
Each control has its own "value" prop: a checkbox uses checked and reads e.target.checked, a radio button uses checked={value === option}, and a <select> takes value on the <select> itself, not selected on an <option>.
Checkbox: e.target.value on a checkbox is the static string "on", a classic bug when copying text-input code.
Radio group: all buttons share one name and one state variable; each compares it with its own option.
Select: value on <select> works like on an input; with multiple it takes an array of strings.
Numbers: e.target.value is always a string; use e.target.valueAsNumber or convert it.
The state starts as undefined, so on the first render the input has no value and React treats it as uncontrolled; after the first keystroke value becomes a string and the input switches to controlled. Start the state with an empty string.
React decides the mode from whether value is undefined (or null), so useState<string>() silently creates an uncontrolled field.
The same thing happens with data that arrives later: value={user?.name} is undefined until the fetch resolves. Use value={user?.name ?? ''} or render the form only once the data is there.
Switching modes mid-life is not just noise: React stops managing the DOM value consistently, and resetting the field may not work.
For security only the user can pick a file: a script cannot set a file input's value (except to clear it), so React has nothing to control. Read the choice from e.target.files in onChange, preview it with URL.createObjectURL, and upload it inside FormData.
Reading: e.target.files is a FileList or null, not an array; take files[0], or Array.from(files) with multiple.
Preview: URL.createObjectURL(file) returns a blob: URL for <img src> without uploading anything. Revoke it when the preview changes and on unmount, or every pick stays referenced until the page closes.
Upload: new FormData(form) includes the File; send it as the fetch body without a Content-Type: the browser sets multipart/form-data with its boundary, and a hand-written header breaks the upload.
Picking the same file again fires no change event, because the value did not change; clear it after handling (input.value = '' or form.reset()).
Validate twice: accept only filters the picker, so check file.type and file.size before uploading and again on the server.
Validate on the client for fast feedback and always again on the server, because client checks can be bypassed. A good default is to validate on submit first, then re-validate a field on change once it has shown an error, so users are not shouted at while still typing.
On submit: the least noisy; React Hook Form's default mode: 'onSubmit' with reValidateMode: 'onChange' gives exactly this.
On blur: good for long forms; the user hears about a field when they leave it.
On change: only for checks the user benefits from live, like a password strength meter or a username availability check (debounced).
Native constraints: required, type="email", minLength and pattern work without JavaScript and block submit; noValidate on the form turns the browser UI off when you render your own messages.
One schema: describe the rules once (Zod) and run the same schema in the browser and on the server, so the two cannot drift.
Server errors: some rules only the server knows (email already taken); map them back onto fields rather than showing a generic toast.
React Hook Form registers inputs as uncontrolled and reads their values from the DOM through refs, so typing does not re-render the form component. It adds what hand-written forms keep reinventing: validation, error state, dirty and touched tracking, submit state and field arrays.
**register('email', rules)**: returns name, ref, onChange and onBlur to spread onto a native input.
**handleSubmit(onValid)**: validates, then calls your function with typed values; it also calls preventDefault.
**formState**: a proxy: the component re-renders only for the parts it reads (errors, isSubmitting, isDirty), so read only what you show.
Subscriptions: useWatch or watch('field') subscribe to one field instead of the whole form.
Cost: another dependency and its own mental model. A three-field form with no rules does not need it.
Describe the form as a Zod schema, pass zodResolver(schema) to useForm, and derive the TypeScript type with z.infer. The rules, the error messages and the type then live in one place, and the same schema can validate the request on the server.
One source of truth: no hand-written type that drifts from the rules.
Cross-field rules: .refine() with a path puts an error such as "passwords do not match" on the right field.
Transforms: strings from inputs can be trimmed or coerced in the schema instead of in every handler.
Server reuse: run schema.safeParse(data) in the API route or server function; it returns { success, data, error } without throwing.
Versions: current is Zod 4 with @hookform/resolvers v5; Zod 4 adds top-level formats like z.email().
Controller wraps a controlled component that does not expose a native input, such as a date picker, react-select or an MUI component, and connects its value and onChange to the form. register only works where it can attach a ref and DOM events to a real input.
The render prop receives field (value, onChange, onBlur, name, ref) and fieldState (error, isTouched).
Pass control from useForm; useController is the hook form of the same thing for building your own field components.
A custom input that forwards ref to a native <input> can keep using register. In React 19 ref is a normal prop, so no forwardRef is needed.
A controlled field re-renders on each change; that is the price of the third-party component's API.
Use useFieldArray({ control, name }): it returns fields to render and append, remove, insert, move to change the list, and it keeps values, errors and focus aligned with the right row. Render each row with key={field.id}, never the index.
**Why field.id**: removing row 2 shifts every index after it, and with key={index} React reuses the wrong DOM input, so values jump between rows.
Names: register nested fields by path, items.${index}.name; the index in the name is fine, it is only the key that must be stable.
Defaults: pass the initial rows in defaultValues; append needs a complete row object.
Validation: a Zod array schema (z.array(item).min(1)) validates both the rows and the list length.
useForm reads defaultValues once, on the first render, when user is still undefined, so the form keeps the empty strings after the query resolves. Render the form only when the data exists, or feed the data in through the values option or reset.
React Hook Form caches defaultValues the same way useState(initial) and an input's defaultValue work: a later value never reaches the fields.
Fix 1 (the code): a child component mounts once user is loaded, so its useForm starts with the real values and an empty form never exists; key={user.id} remounts it for another user.
Fix 2: useForm({ values }) re-syncs the form whenever the data changes. A background refetch then overwrites what the user is typing, unless resetOptions: { keepDirtyValues: true } keeps the edited fields.
Fix 3: reset(user) in an effect when the data arrives, the older idiom, with the same overwrite risk on every refetch.
Late defaults also break isDirty and "reset to saved values", which compare against defaultValues.
Keep one form state for all steps above the step components, so values survive when a step unmounts, and validate only the current step's fields before moving on. Validate the whole schema on the final submit, and again on the server.
One form, many steps: with React Hook Form, one useForm in the wizard plus FormProvider; each step reads it through useFormContext(). Values of unmounted steps stay, because shouldUnregister is false by default.
Per-step validation: trigger(stepFields[step]) checks only those fields against the shared Zod schema; a schema per step, merged at the end, works too.
Enter submits the form: pressing Enter in a field on a middle step fires onSubmit, so route it to "next" rather than to the final submit.
Survive a reload: a long wizard keeps its draft in sessionStorage or a persisted store and the step in the URL (?step=2), so Back and a reload land on the same step.
Guard the order: a step opened by URL checks that the earlier steps are valid, or sends the user to the first invalid one.
Without a library: a useReducer above the steps holds the values; each step merges its fields in on "Next".
Tie each error to its field: set aria-invalid on the input and point aria-describedby at the element holding the message, keep a real <label> for every field, and after a failed submit move focus to the first invalid field.
Labels: a <label htmlFor> or a wrapping <label>; a placeholder is not a label and disappears as soon as the user types.
Ids: useId() generates stable ids for htmlFor and aria-describedby that also match between server and client render.
Announcing: a summary with role="alert" is read out when it appears; do not put role="alert" on every field message or a submit reads out a wall of text.
Focus: React Hook Form focuses the first error by default (shouldFocusError: true); in hand-written forms call .focus() on the field's ref.
Do not rely on colour: a red border alone fails users with colour blindness; show the text.
An Action is an async function that React runs inside a transition, tracking its pending state, errors and optimistic updates for you. Passed to <form action={fn}>, it is called with the form's FormData on submit, with no onSubmit or preventDefault needed.
Pending state: while the action runs, useFormStatus and useActionState report pending, and the UI stays responsive because it is a transition.
Reset: after the action succeeds, React resets the form's uncontrolled fields; requestFormReset from react-dom resets it on demand.
Errors: a thrown error goes to the nearest error boundary; for expected errors (validation) return them as state through useActionState.
Server functions: in a framework with Server Components (Next.js) the action can be a 'use server' function, and the form works even before JavaScript loads.
Buttons: <button formAction={fn}> gives one form several actions.
useActionState(action, initialState) returns [state, formAction, isPending]. Your action receives the previous state and the FormData and returns the next state, so validation errors or a success message become ordinary state that the form renders.
Signature: the action is (prevState, formData) => newState | Promise<newState>; pass formAction to <form action> or a button's formAction.
**isPending: true while the action runs; use it to disable the button or show a spinner.
Queueing: several submits run one after another, each getting the state the previous one returned.
Keep input on error**: uncontrolled fields reset after the action completes; to keep what the user typed, return the submitted values in the state and use them as defaultValue.
Server functions: the third argument, permalink, lets the form work before hydration in a Server Components framework.
History: in React 19 canary it was called useFormState in react-dom; it was renamed and moved to react.
useFormStatus is called in the same component that renders the <form>, and it only reports the status of a parent form, so here pending is always false. Move the button into its own component rendered inside the form.
useFormStatus (from react-dom) reads the nearest <form> above it in the tree, the way a context consumer reads a provider; the form in the component's own output is not above it.
That makes it a natural fit for reusable design-system buttons: a SubmitButton that disables itself in any form, with no props.
Alternatively, keep everything in one component and use isPending from useActionState.
Besides pending, it returns data (the submitted FormData), method and action.
The title field goes back to "Draft" and the tag field keeps "react". After a successful action React resets the form, which returns every uncontrolled field to its defaultValue, while a controlled field shows whatever its state holds, and the state was not touched.
The reset happens only when the action completes without throwing; on error the fields keep their values.
It is the native form.reset() behaviour, done by React after the transition, so defaultValue is what the field returns to.
To clear the controlled field too, call setTag('') in the action, or make it uncontrolled.
To keep the typed title, use a regular onSubmit handler, or return the values in useActionState state and feed them back as defaultValue.
useOptimistic(state, updateFn) returns a value that equals state until you call the setter inside an action; then it shows your guessed value immediately, for as long as that action is running. When the action ends, it falls back to the real state, so on failure the guess simply disappears.
Rollback for free: there is no snapshot to restore; if the action throws or the server rejects, the optimistic value is dropped when the transition ends.
Success: update the real state (or refetch) inside the action; the optimistic value is then replaced by real data in the same commit, with no flicker.
Must be in a transition: calling the setter outside an action or startTransition logs a warning and the value reverts right away.
**updateFn(current, optimisticValue)**: a reducer, useful to append a pending item to a list; mark it (sending: true) to render it greyed out.
Errors: tell the user what failed; a silent rollback looks like the click was ignored.
Block a second submit while the first is pending, and make the server safe against the duplicates that still get through: disable the button by isPending or isSubmitting, and send an idempotency key so a retried request does not create a second order.
On the client
Disable the submit button from the pending flag; a double click or a double Enter otherwise sends two requests before the first re-render.
React 19 queues submits to the same useActionState, so they run one at a time rather than in parallel.
For "latest wins" requests (search, autosave), cancel the previous one with AbortController or ignore responses whose request id is not the latest.
On the server
Generate an idempotency key (crypto.randomUUID()) when the form opens and send it with the request; the server stores it and returns the first result for a repeat.
A unique constraint in the database is the last line of defence for "create" operations.
Why both
The client guard covers the common case; the network can still retry a request after a timeout, and only the server sees that.
Usually the whole form re-renders on each keystroke because every field's value lives in one state object at the top. Profile it with the React DevTools Profiler, then narrow what each keystroke re-renders: keep fields uncontrolled, or push state and subscriptions down to the field that changes.
Measure first: the Profiler's "Highlight updates" and flame graph show which components re-render and how long each takes.
Uncontrolled fields: React Hook Form or plain FormData take typing out of React state entirely.
Narrow subscriptions: in React Hook Form, watch() with no arguments at the root re-renders the whole form; use useWatch({ name }) in the one component that needs the value, and read only the formState keys you render.
Split and memoize: move each section into its own component; React Compiler 1.0 or memo then skips sections whose props did not change.
Expensive derived UI: wrap a heavy preview in useDeferredValue so typing stays responsive while it catches up.
Very long forms: split into steps or virtualise long field lists.
Actions with useActionState cover forms whose rules are checked on submit, especially with server functions, where the form even works before JavaScript loads. React Hook Form still wins for rich client-side forms: per-field validation as you type, dirty tracking, field arrays and many controlled widgets.
Actions are enough when
The form is short, validated on submit, and the server returns the errors (login, newsletter, comment).
You use a Server Components framework and want progressive enhancement: the <form> posts to the server function before hydration.
React Hook Form when
You need errors on blur or change, isDirty to warn about unsaved changes, useFieldArray, or wizard steps sharing one form state.
The form is large enough that controlled state per field would lag.
Combining them
They are not exclusive: validate on the client with RHF and a Zod schema, then call the server function inside handleSubmit (wrapped in startTransition), and validate again with the same schema on the server.