If you ask ten React developers how they handle form state, five will swear by controlled components, three will advocate for useRef, and the remaining two are likely using a library that abstracts the problem entirely.
But here’s the reality for us in 2025: managing form state remains one of the most common sources of technical debt in scaling applications. The decision between Controlled and Uncontrolled components isn’t just about syntax preference; it’s an architectural choice that dictates your application’s rendering performance, code complexity, and integration capabilities.
In this guide, we aren’t just looking at useState vs. useRef. We are looking at the “Source of Truth” problem. By the end of this post, you’ll know exactly which pattern fits your specific use case and how to avoid the re-render traps that plague complex forms.
Prerequisites #
To follow along, you should have a solid grasp of modern React (Hooks API). We will be using TypeScript for our examples because, let’s be honest, you shouldn’t be writing complex forms without type safety in a production environment.
Environment Setup:
- React 18 or 19
- Node.js 20+
- Ideally, a Vite-scaffolded project
# Quick setup if you want to code along
npm create vite@latest react-forms-demo -- --template react-ts
cd react-forms-demo
npm installThe Core Conflict: Who Owns the Data? #
Before writing a single line of code, we need to visualize the fundamental difference. The battle is between React’s Virtual DOM and the browser’s native DOM.
- Controlled: React is the helicopter parent. It watches every keystroke, validates it, and updates the UI. React State is the single source of truth.
- Uncontrolled: React is the hands-off manager. The DOM handles the data; React only checks in when the form is submitted. The DOM is the source of truth.
Data Flow Visualization #
Here is how the data flows in both patterns. Notice the tighter feedback loop in the Controlled approach versus the direct-to-submit path of the Uncontrolled approach.
1. Controlled Components: The “React Way” #
In a controlled component, the form element’s data is handled by a React component. The value attribute is set on the element, and the onChange event listener changes the state.
This is often the default choice because it allows for instant validation, conditional disabling of submit buttons, and dynamic input formatting (like credit card masking).
The Implementation #
Here is a robust implementation including a common requirement: real-time validation error display.
import { useState, ChangeEvent, FormEvent } from 'react';
interface FormState {
email: string;
error: string | null;
}
const ControlledForm = () => {
const [form, setForm] = useState<FormState>({
email: '',
error: null,
});
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
let error = null;
// Real-time validation logic
if (value && !value.includes('@')) {
error = 'Please enter a valid email address.';
}
setForm({
email: value,
error,
});
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (form.error) {
alert("Fix errors before submitting!");
return;
}
console.log('Submitting payload:', form.email);
// API call happens here...
};
return (
<form onSubmit={handleSubmit} className="p-4 border rounded shadow-md">
<h3 className="text-lg font-bold mb-4">Controlled Input</h3>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-medium">Email</label>
<input
id="email"
type="email"
value={form.email} // React controls this
onChange={handleChange}
className={`border p-2 rounded ${form.error ? 'border-red-500' : 'border-gray-300'}`}
/>
{form.error && <span className="text-red-500 text-xs">{form.error}</span>}
</div>
<button
type="submit"
disabled={!!form.error || !form.email}
className="mt-4 bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
Submit
</button>
</form>
);
};
export default ControlledForm;The Performance Trap #
The downside? Re-renders. Every character you type triggers the component function to run again. In a small form, this is negligible. In a form with 50 inputs or a complex dashboard, typing in one field causes the entire parent tree to diff.
2. Uncontrolled Components: The Performance Powerhouse #
Uncontrolled components act more like traditional HTML forms. You don’t write an event handler for every keystroke. Instead, you use a ref to access the DOM node’s value only when you absolutely need it (usually on submit).
This approach separates React’s rendering lifecycle from the input’s internal state.
The Implementation #
We use useRef to hook into the DOM elements. Note that we use defaultValue instead of value.
import { useRef, FormEvent } from 'react';
const UncontrolledForm = () => {
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// We pull data ONLY on submit
const name = nameRef.current?.value;
const email = emailRef.current?.value;
// Basic validation at submission time
if (!name || !email) {
alert("All fields are required");
return;
}
console.log('Submitting payload:', { name, email });
};
return (
<form onSubmit={handleSubmit} className="p-4 border rounded shadow-md mt-6">
<h3 className="text-lg font-bold mb-4">Uncontrolled Input</h3>
<div className="flex flex-col gap-2 mb-2">
<label htmlFor="name-uc" className="text-sm font-medium">Name</label>
<input
id="name-uc"
type="text"
defaultValue="John Doe" // Initial value only
ref={nameRef}
className="border p-2 rounded border-gray-300"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email-uc" className="text-sm font-medium">Email</label>
<input
id="email-uc"
type="email"
ref={emailRef}
className="border p-2 rounded border-gray-300"
/>
</div>
<button
type="submit"
className="mt-4 bg-green-600 text-white px-4 py-2 rounded"
>
Submit
</button>
</form>
);
};
export default UncontrolledForm;Modern Alternative: The FormData API
#
In 2025, using useRef for every single input is tedious. A cleaner “uncontrolled” way that is gaining massive popularity (especially with Server Actions in React frameworks like Next.js) is using the native FormData API.
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
console.log(data); // { name: "...", email: "..." }
};This requires zero refs and zero state. It is the most performant way to handle forms in React.
3. Comparative Analysis #
When should you use which? This decision matrix helps simplify the choice.
| Feature | Controlled Component | Uncontrolled Component |
|---|---|---|
| Source of Truth | React State | DOM |
| Re-rendering | Re-renders on every keystroke | Re-renders only on specific triggers/submit |
| Instant Validation | Easy (Real-time feedback) | Harder (Requires extra listeners) |
| Input Masking | Native support | Difficult / Requires libraries |
| Code Verbosity | High (Boilerplate heavy) | Low (Closer to HTML) |
| Non-React Integration | Difficult | Easy (Works with jQuery/Vanilla JS libs) |
| Performance | Slower for massive forms | Highly performant |
4. Best Practices & Common Pitfalls #
The “Hybrid” Approach #
You don’t have to choose one strictly. A common pattern in high-performance apps is to keep the form Uncontrolled for performance but attach specific listeners only to fields that strictly require real-time validation (like a password strength meter).
Dealing with “A component is changing an uncontrolled input…” #
One of the most famous React warnings is:
Warning: A component is changing an uncontrolled input of type text to be controlled.
The Pitfall: Passing undefined or null as the initial value to a controlled input.
// ❌ WRONG
const [val, setVal] = useState(); // val is undefined
<input value={val} /> // React thinks this is uncontrolled because value is undefined
// later...
setVal("hello"); // React freaks out because it switched to controlled
The Fix: Always initialize your state with an empty string.
// ✅ RIGHT
const [val, setVal] = useState("");
When to use React Hook Form? #
If you find yourself writing complex validation logic for Uncontrolled components, or optimizing performance for Controlled components, stop. Libraries like React Hook Form use the Uncontrolled pattern under the hood (using refs) to provide performance, but expose a Controlled-like API for validation. It is the industry standard in 2025 for a reason.
Conclusion #
The debate between Controlled and Uncontrolled components boils down to control vs. performance.
- Use Controlled Components when you need immediate feedback, such as search bars, instant form validation, or dependent fields (e.g., Country dropdown changing the State dropdown).
- Use Uncontrolled Components (or
FormData) for simple login forms, newsletters, or massive forms where re-rendering the whole tree is a performance bottleneck.
As you build out your application in 2025, lean towards Uncontrolled components for simplicity, and opt-in to Controlled components only when the UX demands that high-frequency interactivity.
Found this guide helpful? Share it with your team or check out our other deep dives into React Performance Patterns.