Forms and controlled inputs
Controlled inputs make state the single source of truth. One change handler for many fields, why checkboxes and number inputs trip people up, and React 19 form actions.
After this lesson you can
- Build a controlled input and explain why state decides what it shows
- Write one change handler for text, number and checkbox fields
- Derive validation errors during render instead of storing them
- Say when an uncontrolled input or a React 19 form action is the simpler choice
An input is controlled when React state decides its value: you pass
value (or checked) and update the state in onChange.
function NameField() {
const [name, setName] = useState("");
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
Each keystroke fires onChange, the handler sets state, React re-renders
and writes the state back into the input. Because the value always passes
through your code, you can transform it or refuse it — and refusing really
works: if the handler does not set state, the input snaps back to the
current state value.
// A controlled "code" field: upper-case, at most 4 characters.function makeField() { let state = ""; // useState("") const onChange = (typed) => { if (typed.length > 4) return; // refuse: do not set state state = typed.toUpperCase(); }; return { type: (typed) => { onChange(typed); return state; } };} export function run() { const field = makeField(); // What the browser proposes after each keystroke, and what is shown after the render: return ["a", "ab", "ab1", "ab1x", "AB1Xz"].map((typed) => `${typed} -> ${field.type(typed)}`);}Two warnings React prints are worth recognising. value without
onChange makes a read-only field (React warns that you probably meant
defaultValue). And value={undefined} followed later by a string
switches the input from uncontrolled to controlled — initialise text state
with "", not undefined.
One handler, many fields
For a form with several fields, keep them in one object and use each
input's name:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, type, value, checked } = e.target;
setValues((v) => ({ ...v, [name]: type === "checkbox" ? checked : value }));
}
A checkbox is controlled with checked, not value, and its state lives
in e.target.checked — for a checkbox without a value attribute,
e.target.value is the string "on" whether it is ticked or not. A
number input still gives you a string in
e.target.value, and an empty field gives "". Keep the string in state
(so the user can type freely) and convert it when you validate:
export function run() { // e.target.value is always a string, even for <input type="number">. const values = ["42", "", "17.5"]; return values.map((v) => ({ value: v, typeofValue: typeof v, "Number(value)": Number(v), isEmpty: v.trim() === "", }));}Validation is derived
Errors are a function of the current values, so compute them during
render: const errors = validate(values). Storing errors in state and
syncing them with an effect is the redundant-state pattern from lesson 8.
Store only what you cannot compute, such as which fields the user has
touched.
Uncontrolled inputs and form actions
Not every field needs state. An uncontrolled input (defaultValue,
with the DOM holding the value) is fine when you only need the value on
submit. React 19 makes that path first-class: <form action={fn}> calls
fn with the form's FormData when it is submitted, works with async
functions, and resets uncontrolled fields after the action succeeds.
useActionState keeps the action's result (for example, server-side
errors) and a pending flag, and useFormStatus (from react-dom) lets a
component rendered inside the form, such as its submit button, read
whether a submission is in progress.
Common mistakes
- Reading
e.target.valuefor a checkbox. Without avalueattribute it is"on"either way; usechecked. - Treating an empty number input as 0.
Number("")is0, so an empty optional field turns into a real value, as the example shows.
Exercise · Pro
A sign-up form, change events in, form state out
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade