Form Inputs and Controlled Values
You don't need React to type inside an <input>. Browser can already handle that perfectly fine on its own.
Put an input in normal HTML, click it, type something, and the browser keeps whatever you typed inside that input. No state, no Hooks, nothing React-specific required.
So then why do React forms keep doing this?
function ReaderName() {
const [name, setName] = useState("");
return (
<label>
Reader name
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
);
}Because sometimes the value inside that input is also needed by the rest of your UI.
Maybe another component displays the reader's name as they're typing. Maybe you need to check whether the value is valid before enabling a button. Maybe changing one field changes some other part of the screen. Once your React UI needs that current value while rendering, storing it in state becomes pretty useful.
In the example above, name is the value React currently knows about. We pass that value into the input using value={name}, and whenever the user edits the field, onChange gives us the new browser value and we put that back into state.
This is what React calls a controlled input.
The input's displayed value comes from React state.
What actually happens when you type one character?
Let's slow this down because controlled inputs can feel slightly weird at first.
On the first render, name is an empty string:
name is ""
input gets value=""Now suppose you type R.
The browser sees that edit, React calls your onChange handler, and inside that handler event.target.value is now "R".
event.target.value is "R"
setName("R") is called
React renders again with name = "R"
input gets value="R"Then you type e, same thing happens again. Then a, same again.
So yeah, React is involved on every edit for a controlled text input.
One React-specific thing here can confuse people coming from plain DOM events. React's onChange for text inputs fires as the value changes while you're typing. If you've worked directly with the native DOM change event before, you might remember that event often behaving later, commonly after the control is committed or loses focus depending on the input type.
React's onChange behaves the way you'd normally expect an input handler to behave: type something, handler runs.
For a controlled input, whatever value or checked came from the current React render is what React expects that control to display.
And this is why your change handler normally needs to update state immediately from the latest input value. If state keeps giving React an old value, React will keep rendering that old value back into the field.
If you give value, you normally need onChange too
Try this:
<input value={name} />Looks harmless.
But how does name ever change?
It doesn't.
You can type into the field, but React keeps saying, "the value for this input is whatever name currently is", and since nothing updates name, your edits can't stick around properly. React will also warn about this in development.
Usually you want:
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>Now there's a complete cycle. State provides the current value, user edits it, handler receives the new value, state changes, React renders again.
Sometimes a read-only input is actually what you wanted though. Maybe you're showing some generated ID that the user can select and copy but shouldn't edit.
<input value={generatedId} readOnly />That's fine. You're telling React that yes, this value is intentionally read-only.
And if you don't actually need input behavior at all, just render normal text. No need to make everything an input because it happens to contain a string.
Start controlled text inputs with a string
If an input is controlled, try to keep it controlled for its whole mounted lifetime.
For text fields, this usually means starting with an empty string:
const [name, setName] = useState("");What you don't want is this:
const [name, setName] = useState(undefined);On that first render, you're effectively not supplying a text value. So the browser is managing it as an uncontrolled field.
Then later maybe some saved profile data arrives and name becomes "Ada".
Now suddenly you're passing a string into value, so the same input has switched from uncontrolled to controlled. React warns about that because you've changed who is supposed to own the current value while the input is already mounted.
If your starting data might be missing, normalize it first:
const [name, setName] = useState(savedName ?? "");Same with null. For a controlled text input, empty means "".
Not undefined, not null.
Just an empty string.
You don't have to control every input
React also supports leaving the value inside the DOM.
For that, you can use defaultValue:
<input name="readerName" defaultValue="Ada" />The browser input starts with "Ada", but after that the DOM keeps track of whatever the user types.
React isn't receiving a state update for every character.
This is an uncontrolled input.
And defaultValue really does mean default. It's the starting value. Changing that prop later isn't the same as controlling the current value.
So how do you read the value then?
One easy way is when the form gets submitted:
function handleSubmit(event) {
event.preventDefault();
const data = new FormData(event.currentTarget);
console.log(data.get("readerName"));
}FormData reads the current values from the form controls in the DOM.
You can also use a ref when you specifically need direct access to one control, though you don't need refs for every uncontrolled form.
Controlled and uncontrolled forms are both valid React code. Pick based on what the UI needs.
If another part of your React UI needs the current value while the person is typing, state is usually convenient. If you only need the value when they submit the form, making React re-render after every keystroke may not give you much.
Number inputs still give you strings
This surprises almost everyone once.
You write:
<input type="number" />So obviously event.target.value is a number now, right?
Nope.
Still a string.
const [weeklyGoal, setWeeklyGoal] = useState("3");<input
type="number"
value={weeklyGoal}
onChange={(event) => setWeeklyGoal(event.target.value)}
/>If the field contains 3, the value you're storing there is "3".
And honestly, keeping it as a string while the user edits the field is often easier.
Because what happens when they clear the field?
For a moment the value is "".
What if they're halfway through typing some numeric value?
Input editing has temporary states that don't always map nicely to the final number your application eventually wants. If you immediately force every keystroke through Number(), you can make normal editing surprisingly irritating.
So keep the raw input value:
const [weeklyGoal, setWeeklyGoal] = useState("3");Then convert it when you actually need to do numeric work:
const numericGoal = Number(weeklyGoal);
const validGoal =
Number.isInteger(numericGoal) &&
numericGoal > 0;There is also event.target.valueAsNumber.
const number = event.target.valueAsNumber;But an empty or invalid number input can give you NaN, so you still need to decide what your component should do with invalid or unfinished input.
No escaping that part, sadly.
Checkboxes don't use value for their checked state
Checkboxes are slightly different.
For a text input, we normally care about value.
For a checkbox, the thing you usually care about is whether it's checked.
function CompletedFilter() {
const [showCompleted, setShowCompleted] = useState(true);
return (
<label>
<input
type="checkbox"
checked={showCompleted}
onChange={(event) =>
setShowCompleted(event.target.checked)
}
/>
Show completed lessons
</label>
);
}event.target.checked gives you a boolean, so you'll get either true or false.
Don't accidentally use this:
event.target.valueCheckboxes do have a value, but that's a separate value used in form submission. If you don't provide one yourself, you'll commonly see "on".
That is not the checkbox's checked state.
For an uncontrolled checkbox, same idea as defaultValue, except the prop is called defaultChecked:
<input type="checkbox" defaultChecked />Radio buttons usually share one state value
Radio buttons look like a bunch of separate inputs in your JSX, but they represent one selection from a group.
Suppose you're filtering lessons:
const [status, setStatus] = useState("all");Now one radio can represent "all":
<label>
<input
type="radio"
name="status"
value="all"
checked={status === "all"}
onChange={(event) => setStatus(event.target.value)}
/>
All lessons
</label>And another one can represent "complete":
<label>
<input
type="radio"
name="status"
value="complete"
checked={status === "complete"}
onChange={(event) => setStatus(event.target.value)}
/>
Complete lessons
</label>You don't need separate state such as allChecked, completeChecked, incompleteChecked and then try keeping all of those booleans in sync.
The actual state is one value:
status === "all"or:
status === "complete"Each radio checks whether its own value matches that state.
Also don't drop the shared name="status". That's part of the browser's radio-group behavior, including how the controls behave with keyboard interaction and normal form submission.
React state doesn't replace those browser semantics.
A select is controlled from the select
Dropdowns follow the same controlled idea:
function ChapterFilter() {
const [chapter, setChapter] = useState("all");
return (
<select
value={chapter}
onChange={(event) => setChapter(event.target.value)}
>
<option value="all">All chapters</option>
<option value="jsx">JSX</option>
<option value="state">State</option>
</select>
);
}Notice that value={chapter} is on the <select>.
You don't do this in React:
<option value="jsx" selected>
JSX
</option>That's normal HTML you may have seen before, but React wants the current selection represented by the value on <select>.
So if:
chapter === "jsx"React knows the option with value="jsx" should be selected.
Multiple selects work too:
<select multiple value={selectedChapters}>In that case value is an array of selected option values, and your change handler has to read the selected options instead of just one event.target.value.
We don't really need that for the first form though.
Textareas use value too
Normal HTML lets you write initial textarea content between the tags:
<textarea>Hello</textarea>In React, you normally use value for a controlled textarea:
const [note, setNote] = useState("");<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
/>And for an uncontrolled one:
<textarea defaultValue="Add a note" />So don't put your React textarea value between opening and closing tags and then try treating it as normal children. Use value or defaultValue.
File inputs are a bit different
Can we do this?
<input
type="file"
value={somePath}
onChange={...}
/>Not in the normal controlled-input sense.
Browsers don't let JavaScript assign arbitrary local file paths into a file input. Imagine what websites could do if they were allowed to silently choose files from your machine.
So the user picks the file, browser owns that selection, and then your code reads what was selected:
<input type="file" onChange={handleFileChange} />function handleFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
console.log(file.name);
}event.target.files contains the user's selected files.
From there you might show a preview, check the size, inspect its MIME type, upload it, whatever the feature needs.
Just remember that the browser is still the one controlling the actual selected-file value.
How much state should a form have?
For a small form, I'd keep it boring.
const [name, setName] = useState("");
const [weeklyGoal, setWeeklyGoal] = useState("3");
const [sendReminder, setSendReminder] = useState(false);Three fields, three state values. You can look at the code and immediately tell that name is text, weeklyGoal currently stores text from a number input, and sendReminder is a boolean.
Could we put them all in an object?
Of course.
const [form, setForm] = useState({
name: "",
weeklyGoal: "3",
});Then update one field like this:
setForm((current) => ({
...current,
name: nextName,
}));That spread is doing important work because React state setters don't merge objects for you.
If you do:
setForm({
name: nextName,
});then congratulations, weeklyGoal is gone from that object.
You replaced the entire state value with a new object containing only name.
For larger forms, grouping fields can make sense depending on how the form behaves. For our small examples though, separate state is easier to read and there's less code involved.
Handle submission on the form
If you've got a form, use the form's submit event.
function handleSubmit(event) {
event.preventDefault();
console.log({
name,
weeklyGoal,
});
}Then:
<form onSubmit={handleSubmit}>
<label>
Reader name
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<button type="submit">
Save goal
</button>
</form>Why put it on <form> instead of only adding an onClick to the button?
Because submitting a form isn't limited to clicking that button. Depending on the controls involved, the browser can also submit through keyboard interaction. Let the form handle form submission.
For this client-only example, event.preventDefault() stops the browser from doing its normal form submission and loading another document.
Then we can do whatever our React app needs instead.
Right now that's only:
console.log(...)Obviously a real save flow probably sends data somewhere, shows pending state, handles errors, and then shows some success result after the server accepts it.
We'll get there.
Don't throw away browser validation
React doesn't mean you have to rebuild every piece of form behavior yourself.
HTML already gives us things such as input types and validation attributes:
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>The browser understands type="email" and required. It can stop invalid form submission and provide its own validation UI without us writing another state variable just to recreate what HTML already knows.
You can still have React validation as well:
const emailLooksValid = email.includes("@");Maybe you want custom guidance before submission, or maybe another part of the UI depends on whether the email looks valid.
Fine.
Just don't confuse a browser or React validation check with trusted validation.
Someone can send an HTTP request to your server without using your React page at all. So whatever data reaches the server has to be validated again there.
And if you're showing your own error message, connect it properly to the input:
<input
aria-describedby="email-error"
aria-invalid={!emailLooksValid}
/>
<p id="email-error">
Enter a complete email address.
</p>You probably also don't want that error screaming at someone before they've typed even one character. When exactly an error should appear is another decision for the form.
Let's build the reading goal form
So now our little form can start with three pieces of controlled state:
function ReadingGoalForm() {
const [name, setName] = useState("");
const [weeklyGoal, setWeeklyGoal] = useState("3");
const [sendReminder, setSendReminder] = useState(false);
// return form
}The name input gets name and updates it with setName.
The number input gets weeklyGoal and updates that string with setWeeklyGoal.
The checkbox gets sendReminder through checked, then updates it from event.target.checked.
And when the form is submitted:
function handleSubmit(event) {
event.preventDefault();
console.log({
name,
weeklyGoal,
sendReminder,
});
}There's no separate copy of the form data hiding somewhere. The handler sees the state values from the render that created that handler.
So who owns each value?
This is probably the question I'd ask whenever some form input starts behaving strangely.
Who currently owns its value?
For a controlled text input, React state owns the string and passes it through value.
For a controlled checkbox, React state owns the boolean and passes it through checked.
A controlled radio group normally has one selected value in React state, and every radio checks whether that value equals its own.
A controlled <select> gets its current value from React state as well.
An uncontrolled field keeps its changing value inside the DOM after the initial defaultValue or defaultChecked.
And a file input leaves the selected files with the user and browser, while your code reads the result afterward.
Once you know who currently owns the value, form code becomes a lot less confusing.
If React owns it, the current render needs to pass that value back into the control and your change handler needs to update state.
If the DOM owns it, leave it there until you actually need to read it.
That's really the main idea behind controlled and uncontrolled form inputs. The rest is mostly learning which property each HTML control uses.