Skip to main content

Controlled and uncontrolled components in React

Controlled component stores form value in React state and syncs it on every keystroke. Uncontrolled component lets the DOM hold the value and you read it via ref only when needed.

Theory

TL;DR

  • Controlled: React state is the source of truth, onChange fires on every keystroke and triggers a re-render
  • Uncontrolled: DOM holds the value, you read it via ref on demand
  • Analogy: controlled is a live chat that syncs every character to the server; uncontrolled is a paper form you fill out and hand in at the end
  • Use controlled for real-time validation or conditional rendering
  • Use uncontrolled for simple forms, file inputs, or third-party library integration

Quick example

jsx
// CONTROLLED: React owns the value function Controlled() { const [name, setName] = useState(""); return ( <input value={name} onChange={(e) => setName(e.target.value)} /> // name is always in sync with what the user typed ); } // UNCONTROLLED: DOM owns the value function Uncontrolled() { const inputRef = useRef(); return ( <> <input ref={inputRef} /> <button onClick={() => console.log(inputRef.current.value)}> Submit </button> {/* value is read only on button click */} </> ); }

In controlled, value and onChange are always paired. In uncontrolled, the ref points directly to the DOM node.

Key difference

In a controlled component, React state is the single source of truth. Every keystroke fires onChange, updates state, and triggers a re-render with the new value prop. In an uncontrolled component, the browser updates the DOM directly without notifying React. When you read inputRef.current.value, you bypass React entirely and read from the DOM node. This means a controlled input is always in sync with React state, while an uncontrolled input can hold a value React knows nothing about.

When to use

Controlled:

  • Real-time validation - disable the submit button while an email field is invalid
  • Conditional rendering based on input - show autocomplete suggestions as the user types
  • Pre-filling fields with data from an API or props
  • Any situation where React needs to respond to every change

Uncontrolled:

  • Simple forms where you only need the value on submit
  • File inputs (<input type="file">) - browsers block setting value programmatically for security reasons
  • Integrating third-party DOM libraries that manage their own state
  • Performance-sensitive forms with many fields where re-rendering on every keystroke causes visible lag

Comparison table

AspectControlledUncontrolled
State locationReact stateDOM element
Sync timingEvery keystrokeOn demand (via ref)
Re-renderYes, on every changeNo, unless you trigger it manually
ValidationReal-time possibleOnly on submit
Initial value propvaluedefaultValue
When to useValidation, conditional UI, pre-fillSimple forms, file inputs, third-party libs

How it works internally

When you type in a controlled input, the browser fires an onChange event. React's handler calls useState setter, schedules a re-render, diffs the virtual DOM, and patches the actual DOM with the new value prop. Everything happens in the same event cycle.

For uncontrolled inputs, the browser updates the DOM directly. React's virtual DOM never sees the change. Reading inputRef.current.value goes straight to the DOM node, skipping React completely. No re-render, no state update, no diffing.

Common mistakes

1. Using value without onChange

jsx
// WRONG: input becomes read-only <input value={name} /> // RIGHT: always pair them <input value={name} onChange={(e) => setName(e.target.value)} />

React sets the value prop but has no way to update it. The user cannot type anything. React also logs a warning in the console about this exact issue.

2. Using value instead of defaultValue in uncontrolled components

jsx
// WRONG: this creates a controlled input with no onChange handler <input ref={inputRef} value="initial" /> // RIGHT: defaultValue sets the initial value without taking control <input ref={inputRef} defaultValue="initial" />

3. Reading a ref before the component mounts

jsx
// WRONG: inputRef.current is null during the first render function Form() { const inputRef = useRef(); const value = inputRef.current.value; // TypeError: null return <input ref={inputRef} />; } // RIGHT: read refs in event handlers or effects function Form() { const inputRef = useRef(); const handleClick = () => { console.log(inputRef.current.value); // safe here }; return <input ref={inputRef} />; }

4. Initializing controlled state as undefined

jsx
// WRONG: undefined means no value prop, so React treats input as uncontrolled const [name, setName] = useState(); <input value={name} onChange={(e) => setName(e.target.value)} /> // RIGHT: always initialize with an empty string const [name, setName] = useState(""); <input value={name} onChange={(e) => setName(e.target.value)} />

React will warn: "A component is changing an uncontrolled input to be controlled." This happens because undefined equals no value prop on the first render.

5. Running expensive operations on every keystroke

jsx
// SLOW: expensiveSearch fires on every character typed const handleChange = (e) => { setSearch(e.target.value); expensiveSearch(e.target.value); }; // BETTER: debounce the expensive part, update state immediately const handleSearch = useCallback( debounce((value) => expensiveSearch(value), 300), [] ); const handleChange = (e) => { setSearch(e.target.value); handleSearch(e.target.value); };

Real-world usage

  • React Hook Form defaults to uncontrolled inputs for performance - it stores values in a ref, not state, which avoids re-rendering the form on every keystroke
  • Material-UI and Chakra UI pass value and onChange to all form components - controlled pattern by default
  • <input type="file"> is always uncontrolled because browsers block programmatic value setting for security
  • Redux forms store field values in Redux state - the controlled pattern at a global level
  • Next.js Server Actions pair naturally with uncontrolled forms because you can pass a FormData object directly to the action without any state involved

Follow-up questions

Q: Why does React warn "You provided a value prop without an onChange handler"?
A: Because the input becomes read-only. React sets the value but has no mechanism to update it, so users cannot type. React detects the mismatch early so you don't waste time debugging a frozen input field.

Q: Can a component switch from uncontrolled to controlled during its lifetime?
A: No. React throws an error if you change a component from uncontrolled to controlled or vice versa. Pick one pattern and keep it for the component's entire lifetime.

Q: What is the actual performance difference?
A: Controlled components re-render on every keystroke. With large forms or expensive render logic this causes visible lag. Uncontrolled components skip React's render cycle entirely, but you lose real-time validation. React Hook Form solves this by using uncontrolled inputs internally while exposing a controlled-like API.

Q: A form with 50 fields - controlled or uncontrolled?
A: Controlled with optimization. Split the form into smaller sub-components, memoize handlers with useCallback, and debounce any expensive operations. Or use React Hook Form, which handles this internally and gives you both performance and real-time validation.

Q: (Senior) Why does React Hook Form default to uncontrolled components, and when would you override that?
A: React Hook Form stores values in a ref instead of state to avoid re-rendering the entire form on every keystroke. You switch to controlled mode (via the Controller component or useController hook) when integrating with controlled UI libraries like Material-UI, or when you need conditional rendering that reacts to live input values.

Examples

Controlled form with live email validation

jsx
function SignupForm() { const [email, setEmail] = useState(""); const [error, setError] = useState(""); const handleChange = (e) => { const value = e.target.value; setEmail(value); if (value && !value.includes("@")) { setError("Invalid email"); } else { setError(""); } }; return ( <div> <input value={email} onChange={handleChange} placeholder="Email" /> {error && <span style={{ color: "red" }}>{error}</span>} {/* error appears and disappears as the user types */} </div> ); }

The error message reacts in real time because controlled inputs re-render on every keystroke. With an uncontrolled input you'd only catch the invalid email after the user hits submit.

Uncontrolled form for simple submission

jsx
function ContactForm() { const nameRef = useRef(); const emailRef = useRef(); const handleSubmit = (e) => { e.preventDefault(); const data = { name: nameRef.current.value, email: emailRef.current.value, }; console.log(data); // both values read at once on submit }; return ( <form onSubmit={handleSubmit}> <input ref={nameRef} defaultValue="" placeholder="Name" /> <input ref={emailRef} defaultValue="" placeholder="Email" /> <button type="submit">Send</button> </form> ); }

No re-renders happen as the user types. React gets involved only at submit. This works well for simple contact forms where you don't need any live feedback.

Why mixing both patterns causes confusion

jsx
// DON'T DO THIS function BadMix() { const [value, setValue] = useState(""); const inputRef = useRef(); return ( <> <input value={value} onChange={(e) => setValue(e.target.value)} ref={inputRef} /> <button onClick={() => console.log(inputRef.current.value)}> Log value </button> {/* ref reads the DOM node correctly here, but so does value state */} {/* two ways to get the same data - pick one */} </> ); }

The ref reads the DOM node correctly here. But the state already holds the same value. Using both creates unnecessary confusion about which one to trust. I have seen this pattern in code reviews often enough - it usually comes from developers who learned jQuery first and try to keep a foot in both worlds. Pick one approach and stay consistent.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?