There is a pattern so deeply ingrained in React codebases that most developers don’t even question it anymore. You add a form, and immediately reach for useState — one per field, sometimes a useEffect to sync derived state, maybe a custom hook to “clean it up”. Before you know it, a simple contact form is managing six state variables and re-rendering on every keystroke.
The browser already knows how to handle input state. It has for thirty years. React doesn’t need to re-implement that.
The typical trap
Here is what the over-engineered version looks like:
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage({ name, email, message });
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<textarea value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="submit">Send</button>
</form>
);
}
Every character the user types triggers a state update, which triggers a re-render. The component rerenders on every single keystroke across all three fields, for a form that only needs the values when the user clicks submit. That is a lot of work for nothing.
Just read the form on submit
HTML forms have a submit event. That event gives you access to the form element. The form element contains all your inputs. FormData wraps that up into a clean API:
function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
sendMessage({
name: data.get("name") as string,
email: data.get("email") as string,
message: data.get("message") as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" type="email" />
<textarea name="message" />
<button type="submit">Send</button>
</form>
);
}
Zero state. Zero re-renders on keystroke. The browser manages the input values the way it always has, and you read them exactly once — when you actually need them.
The only requirement is that your inputs have a name attribute. Which they probably should anyway for accessibility and autofill to work correctly.
What about default values?
If you need to pre-populate a field — for an edit form, for example — use defaultValue instead of value. This keeps the input uncontrolled (the browser owns the state) while still initialising it with data:
<input name="email" defaultValue={user.email} />
Compare this to the controlled version, where you’d be forced to seed a useState call with the prop value and carefully handle what happens if the prop changes. With defaultValue, you just… pass it in. Done.
When you actually need useState on an input
There are real cases where reading the form on submit isn’t enough, and you genuinely need React to track the value live:
Real-time validation that depends on multiple fields. Password confirmation is the classic example. You need to compare two fields as the user types, and show an inline error before they submit. Knowing the value at submit time is too late.
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const mismatch = confirm.length > 0 && password !== confirm;
Derived UI state that updates as you type. A character counter, a live slug preview from a title input, a search box that filters results inline — these legitimately need the value on every change.
Conditional fields. If selecting one option should show or hide a completely different section of the form, you need React to know about that selection:
const [type, setType] = useState("personal");
return (
<form>
<select name="type" value={type} onChange={(e) => setType(e.target.value)}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{type === "business" && <input name="company" placeholder="Company name" />}
</form>
);
Note that even here, only the controlling field needs to be in state. The company input below it can remain uncontrolled.
The ref escape hatch
Sometimes you need to read or focus a specific input imperatively — without re-renders and without making it fully controlled. useRef works well here:
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
const clear = () => {
if (inputRef.current) inputRef.current.value = "";
};
return (
<div>
<input ref={inputRef} name="query" />
<button type="button" onClick={clear}>
Clear
</button>
</div>
);
}
You’re directly mutating the DOM node, which React normally discourages. But for imperative operations like focus, clearing, or reading a value outside of an event handler, it’s the right tool.
The actual rule of thumb
The question to ask before adding useState to an input is: does this component need to react to changes as the user types?
If the answer is no — you only care about the value when the user submits, clicks a button, or leaves the field — then you do not need controlled state. Let the browser handle it and use FormData or e.target to read the values when it matters.
If the answer is yes, because you need to validate live, render something conditionally, or derive other state from the field value, then useState is the right call. But it should be a deliberate choice, not a reflex.
Most forms in most applications are submit-and-process. Login forms, registration forms, contact forms, settings pages, search boxes that submit on enter. None of these need to re-render on every keystroke. Most of the useState in your form components probably shouldn’t be there.
The browser is a capable platform. FormData exists. Lean on them.