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.
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.
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.
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.
I’ve found that juniors often over-engineer simple forms. Here is how I decide which path to take:
Use Controlled Components when:
Use Uncontrolled Components when:
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.
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.
React performance depends on knowing when to memoize. Learn the mental model for using useMemo and useCallback effectively without falling into optimization traps.