Concept
The beginner framing: forms in React can be built two ways, letting React's state drive every input's value, or letting the DOM manage the input and only asking it for the value when you need it.
The precise mental model: every form input has exactly one question to answer, who owns its current value, React state or the DOM itself?
Controlled: React state owns the value
function ControlledForm() {
const [email, setEmail] = useState("");
return (
<input
value={email} // React DRIVES the displayed value
onChange={(e) => setEmail(e.target.value)} // every keystroke updates state
/>
);
}The input's displayed value is always exactly email, React re-renders on every keystroke (see State), and the DOM input never has a value of its own that React doesn't already know about. This makes validating or transforming input as the user types trivial, at the cost of a re-render per keystroke.
Uncontrolled: the DOM owns the value
function UncontrolledForm() {
const emailRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log(emailRef.current.value); // read the DOM's value ON DEMAND
}
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} defaultValue="" /> {/* DOM manages its own value */}
<button type="submit">Submit</button>
</form>
);
The input manages its own value entirely, React only reaches in via emailRef.current.value (see Refs & the DOM) when it actually needs it, typically on submit. Typing into this input causes zero React re-renders, the keystrokes never touch React's rendering system at all.
Why React Hook Form defaults to uncontrolled
Libraries like React Hook Form register inputs as uncontrolled (via refs) specifically to avoid a re-render on every keystroke across a form with many fields, with a controlled approach, typing in one field of a 50-field form can re-render all 50 fields' worth of subtree on every character, unless carefully scoped. By reading values on demand (on blur, on submit, or via a subscription only the interested field listens to) instead of on every keystroke, RHF keeps large forms fast without giving up validation.
import { useForm } from "react-hook-form";
function Signup() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email", { required: true })} />
{errors.email && <span>Email is required</span>}
<button type=
register("email", ...) wires the input up as uncontrolled internally (a ref plus native event listeners), the component only re-renders when something it actually needs to display changes, like a validation error appearing, not on every keystroke.
Try It
Predict the number of re-renders before checking the solution.
function CompareInputs() {
const [controlledValue, setControlledValue] = useState("");
const uncontrolledRef = useRef(null);
const renders = useRef(0);
renders.current += 1;
return (
<div>
<p>Component has rendered {renders.current} times</p>
<input value={controlledValue} onChange={(e) => setControlledValue(e.target.value)}
Typing 5 characters into the FIRST input, then 5 characters into the SECOND input, how many total renders?
Solution
Roughly 5 renders (one per keystroke in the first, controlled input, each onChange calls setControlledValue, scheduling a re-render). Typing into the second, uncontrolled input adds zero additional renders, the DOM updates its own displayed value natively, and nothing calls a state setter, so React never even knows those keystrokes happened until something explicitly reads uncontrolledRef.current.value.
Implement It Yourself
Build a minimal register-style helper that mirrors React Hook Form's core idea, wiring up an uncontrolled input with validation, without a re-render per keystroke:
function useSimpleForm() {
const values = useRef({});
const [errors, setErrors] = useState({});
function register(name, { required } = {}) {
return {
name,
defaultValue: "",
onChange: (e) => {
values.current[name] = e.target.value; // stored in a REF, no re-render
},
onBlur: (e) => {
if (required && !e.target.value) {
setErrors((prev
Every keystroke calls onChange, which writes into values.current, a plain ref mutation, invisible to React's render cycle. The component only re-renders when setErrors actually fires, which happens on blur, not on every character. This is precisely the trade RHF makes at a larger scale: read from the DOM/refs on demand, re-render only when something the UI needs to show (an error message, a submit state) actually changes.
Under the Hood
Uncontrolled inputs are a direct, practical application of Refs & the DOM: the DOM node owns its own value, and a ref is the sanctioned way to read that value without React's re-render machinery getting involved. Controlled inputs are the ordinary State pattern applied to a specific DOM element, nothing new, just value/onChange wired to a state variable and its setter, with the render-per-keystroke behavior state always has.
Common Mistakes
1. Switching an input between controlled and uncontrolled during its lifetime
const [email, setEmail] = useState(); // ❌ starts undefined
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// React warns: "component is changing an uncontrolled input to be controlled"If value is undefined on the first render, React treats the input as uncontrolled; once email gets set to a real string, it suddenly becomes controlled, React explicitly warns about this, because it changes which system (DOM vs React) owns the value mid-flight. Fix: always initialize controlled state to a real value (useState(""), never useState()).
2. Providing value without onChange (or vice versa)
<input value={email} /> {/* ❌ no onChange, the input becomes READ-ONLY */}A controlled input with a value but no onChange renders the correct initial value, but every keystroke is immediately overridden back to email on the very next render since nothing ever updates the state, the input appears frozen/read-only. Always pair value with onChange, or drop value entirely and go uncontrolled with defaultValue.
3. Putting every field's state in one large parent object, causing the whole form to re-render on any keystroke
const [form, setForm] = useState({ name: "", email: "", address: "", /* ...47 more fields */ });
// any single keystroke → setForm({ ...form, name: e.target.value }) → the WHOLE form subtree re-rendersFor small forms this is harmless; for large forms, every keystroke re-rendering dozens of unrelated fields is exactly the performance problem React Hook Form's uncontrolled-by-default design exists to avoid.
Best Practices
- Use controlled inputs for small forms or when you need to validate/transform as the user types, the re-render cost is negligible at a handful of fields.
- Reach for uncontrolled inputs (or a library like React Hook Form) for large forms, where a re-render per keystroke across dozens of fields becomes a real, measurable cost.
- Never half-control an input, always pair
valuewithonChange, or usedefaultValueand skipvalueentirely. - Validate on blur or submit for expensive checks (an API call, a heavy regex), reserving on-every-keystroke validation for genuinely cheap checks (a required-field indicator).
Performance Tips
- A controlled input's re-render is scoped to whatever subtree actually re-renders from that state update, colocating each field's state as close to that input as possible (rather than one giant parent object) limits the blast radius, the same lesson from Rendering Lifecycle's cascade behavior.
- Large forms benefit disproportionately from uncontrolled patterns specifically because typing never touches React's render cycle at all, not "renders less," but zero renders per keystroke, which is why libraries built for large forms default to it.
