React form handling: Controlled vs. Uncontrolled Components
React form handling doesn't have to be complex. Learn the trade-offs between controlled and uncontrolled components to decide when to sync your UI state.
During my first year as a frontend dev, I treated every <input> like it needed a useState hook. I thought if it wasn’t in state, it wasn’t "React-y" enough. I spent hours debugging performance issues in a simple search bar that re-rendered the entire page on every keystroke, only to realize I was fighting the DOM instead of working with it.
Understanding the React Form Handling Spectrum
The choice between controlled and uncontrolled components is a fundamental pillar of React state management. When you build forms, you aren't just capturing data; you're deciding who the "source of truth" is: the React component tree or the browser's internal DOM state.
The Controlled Approach
In a controlled component, the input's value is driven entirely by React state. Every onChange event triggers a state update, which forces a re-render, effectively pushing the new value back down into the input.
JAVASCRIPTconst [name, setName] = useState(CE9178">''); <input value={name} onChange={(e) => setName(e.target.value)} />
This is powerful because it gives you immediate access to the value. You can perform real-time validation, disable buttons based on input length, or format the text as the user types. However, as I learned the hard way, if you have a form with 50+ inputs, every single keystroke triggers a component lifecycle cycle. If your parent component isn't optimized, you'll see a noticeable lag—often around 100ms or more on lower-end devices.
The Uncontrolled Approach
Uncontrolled components let the DOM handle the state. You use a useRef hook to grab the value only when you actually need it, like when the user clicks "Submit."
JAVASCRIPTconst inputRef = useRef(null); const handleSubmit = () => { console.log(inputRef.current.value); }; <input ref={inputRef} />
This is significantly faster for large forms because you skip the overhead of constant re-renders. It’s the "old school" way, but it's incredibly efficient for simple data entry tasks where you don't need to manipulate the UI based on every character typed.
Choosing Your Strategy
I’ve found that juniors often over-engineer simple forms. Here is how I decide which path to take:
-
Use Controlled Components when:
- You need real-time validation (e.g., "Password too weak").
- You need to conditionally disable the submit button.
- You are building a complex UI that changes based on input (e.g., dynamic fields).
- You need to support "instant search" filters.
-
Use Uncontrolled Components when:
- You have a large form with many inputs.
- You only need the data upon submission.
- You want to minimize re-renders to keep the app snappy.
- You are migrating legacy forms into React.
Why the Mental Model Matters
When you understand React rendering: Why state updates re-run your components, you realize that every state update has a cost. If you're building a massive dashboard, keeping every field in global state might trigger unnecessary reconciliation cycles.
I once tried to move a massive multi-step form entirely into a global Redux store. It broke because the performance overhead of syncing the DOM with the store on every single character was too high. We eventually switched to uncontrolled inputs for the heavy lifting and only synced the final output to our state management layer. It was a massive win for performance, dropping our input latency by roughly 40ms.
A Few Caveats
Don't get caught in the trap of thinking one is "better." They are just tools. Uncontrolled components are harder to test with certain libraries like React Testing Library if you're trying to simulate specific user interactions, while controlled components require more boilerplate code.
Next time you start a form, ask yourself: "Does the UI need to change while the user types?" If the answer is no, stop reaching for useState. Use a ref, grab the value at the end, and save your app the extra work. I'm still refining my own approach—sometimes I'll start with uncontrolled to get a feature out the door, then refactor to controlled if the requirements shift toward complex validation. It's okay to change your mind as the feature evolves.