Controlled Elements: How React Takes Ownership of Form Inputs
By default, form inputs store their value in the DOM. Controlled elements move that value into React state, making the form fully predictable and resettable.
I used to grab form values with document.querySelector('input').value.
Then I switched to React and kept doing the same thing, just inside a submit handler. It worked. Until it did not.
The input showed one value. My handler read another. The bug took thirty minutes to track down, and the fix was three lines.
That fix is controlled elements.
Why Inputs Are Different
A regular <div> is passive. You put text in it, React puts text in it. Either way, the DOM reflects whatever you last set.
A form input is different. It maintains its own internal state. When a user types into an <input>, the browser updates the element's value property directly, without going through React. The DOM and React are both managing the same element, but neither one knows what the other is doing.
This is the same "state in the DOM" problem that React was built to solve, just inside a form element instead of a counter button.
The Controlled Elements Technique
The solution is to make React the single owner of the input's value. Three steps.
Step 1: Create a piece of state.
const [description, setDescription] = useState('');Step 2: Connect the state to the input's value prop.
<input type="text" value={description} />Now React is in charge. The input can only show what description contains. The user can type all they want, but the displayed value will not change because the state is not updating yet.
Step 3: Listen for changes and update the state.
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>Now when the user types, the change event fires, the handler updates description, React re-renders, and the input shows the new value. The loop is complete.
ExpandA circular flow diagram: user types into input, change event fires, onChange calls setDescription with e.target.value, React re-renders, input displays new description value, back to user.
React owns the state. The DOM reflects it. They are always in sync.
The String Gotcha
e.target.value is always a string. Even for <input type="number"> and <select> elements, the value comes back as a string.
const [quantity, setQuantity] = useState(1); // number
<select
value={quantity}
onChange={(e) => setQuantity(e.target.value)} // bug: sets a string
/>After the first change, quantity becomes "2" instead of 2. Any arithmetic downstream produces "21" instead of 3.
Fix it at the update site:
onChange={(e) => setQuantity(Number(e.target.value))}Convert before setting state. Do not wait until you need to use the value.
The Reset Payoff
Because React owns the form state, resetting it is just resetting state variables.
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const newItem = { description, quantity, packed: false, id: Date.now() };
// do something with newItem
setDescription(''); // form resets
setQuantity(1); // form resets
}No form.reset(). No document.querySelectorAll('input'). The form goes back to its initial state because the state it was displaying went back to its initial state.
This is the exact payoff of the controlled elements model: the form is just a view of your state. Change the state, the form changes with it.
Uncontrolled Inputs
An uncontrolled input is one where the DOM, not React, owns the value. You read it out of the DOM using a ref when you need it.
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit() {
console.log(inputRef.current?.value); // reading from DOM
}
<input ref={inputRef} type="text" />This works fine for simple cases like a search box you only need to read on submit. It does not work when you need the value during typing, for validation, dependent fields, or live previews.
For any form that needs to respond to user input as it happens, use controlled elements. For simple read-on-submit scenarios, uncontrolled is acceptable.
The Essentials
- Form inputs maintain their own state in the DOM by default. Controlled elements override this by binding
valueto a React state variable, making React the single source of truth. - Three steps: create state, set
value={state}on the element, update state withonChange={(e) => setState(e.target.value)}. All three are required. Missing theonChangemakes the input read-only. e.target.valueis always a string. Convert withNumber()before setting state for numeric inputs. Reset the form by resetting the state variables.
Further Reading and Watching
- Reacting to Input with State -- React Docs: The official guide to thinking declaratively about form state in React.
- Referencing Values with Refs -- React Docs: When refs and uncontrolled inputs are the right choice instead.
Keep reading