Two Ways to Handle Form Inputs
In HTML, form elements like <input>, <textarea>, and <select> maintain their own state internally โ the DOM holds the current value. React offers two approaches to work with this:
- Controlled: React state is the single source of truth. Every keystroke updates state, which updates the input.
- Uncontrolled: The DOM holds the value. You read it with a ref when you need it.
Controlled:
User types --> onChange --> setState --> React re-renders --> input shows new value
React state is the truth. Input reflects state.
Uncontrolled:
User types --> DOM updates internally
When needed --> ref.current.value reads the DOM
DOM is the truth. React does not track each keystroke.
Controlled Components
A controlled component has its value driven by React state. You provide a value prop and an onChange handler.
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting:', { email, password });
};
return (
<form onSubmit={handleSubmit}>
<label>
Email:
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
Password:
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit">Log In</button>
</form>
);
}
Every keystroke triggers onChange, which calls setEmail or setPassword, which triggers a re-render, which updates the input's displayed value.
Controlled Select
function CountrySelect() {
const [country, setCountry] = useState('us');
return (
<select value={country} onChange={(e) => setCountry(e.target.value)}>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
);
}
Controlled Textarea
function CommentBox() {
const [comment, setComment] = useState('');
const maxLength = 280;
return (
<div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={maxLength}
rows={4}
/>
<p>{comment.length}/{maxLength}</p>
</div>
);
}
Controlled Checkbox and Radio
function Preferences() {
const [newsletter, setNewsletter] = useState(false);
const [plan, setPlan] = useState('free');
return (
<form>
<label>
<input
type="checkbox"
checked={newsletter}
onChange={(e) => setNewsletter(e.target.checked)}
/>
Subscribe to newsletter
</label>
<fieldset>
<legend>Plan</legend>
{['free', 'pro', 'enterprise'].map(option => (
<label key={option}>
<input
type="radio"
name="plan"
value={option}
checked={plan === option}
onChange={(e) => setPlan(e.target.value)}
/>
{option.charAt(0).toUpperCase() + option.slice(1)}
</label>
))}
</fieldset>
</form>
);
}
Why Use Controlled Components
- Instant validation: validate on every keystroke
- Conditional disabling: disable submit button until form is valid
- Formatting: transform input as user types (phone numbers, credit cards)
- Multiple inputs from one source: derived/computed values
- Full control: you decide what the input value is at all times
// Real-time formatting: credit card number
function CreditCardInput() {
const [value, setValue] = useState('');
const formatCard = (raw) => {
const digits = raw.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
};
return (
<input
value={value}
onChange={(e) => setValue(formatCard(e.target.value))}
placeholder="1234 5678 9012 3456"
/>
);
}
This is impossible with uncontrolled components โ you cannot intercept and transform the value before it reaches the DOM.
Uncontrolled Components
An uncontrolled component lets the DOM manage the input state. You use a ref to read the value when needed (usually on submit).
import { useRef } from 'react';
function LoginForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const email = emailRef.current.value;
const password = passwordRef.current.value;
console.log('Submitting:', { email, password });
};
return (
<form onSubmit={handleSubmit}>
<label>
Email:
<input type="email" ref={emailRef} defaultValue="" />
</label>
<label>
Password:
<input type="password" ref={passwordRef} defaultValue="" />
</label>
<button type="submit">Log In</button>
</form>
);
}
Notice defaultValue instead of value. This sets the initial value without making React the owner of the input state.
Uncontrolled File Input
File inputs are always uncontrolled in React โ there is no way to programmatically set a file input's value.
function FileUpload() {
const fileRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const file = fileRef.current.files[0];
if (file) {
console.log('Selected file:', file.name, file.size);
// Upload logic here
}
};
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileRef} accept="image/*" />
<button type="submit">Upload</button>
</form>
);
}
Using FormData (Fully Uncontrolled)
The FormData API reads all form values at once without refs:
function ContactForm() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
console.log(data);
// { name: "Alice", email: "[email protected]", message: "Hello" }
};
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue="" placeholder="Name" />
<input name="email" type="email" defaultValue="" placeholder="Email" />
<textarea name="message" defaultValue="" placeholder="Message" />
<button type="submit">Send</button>
</form>
);
}
No useState, no useRef, no re-renders on keystroke. The form is entirely DOM-managed.
Why Use Uncontrolled Components
- Simple forms: no validation, no formatting, just collect and submit
- Large forms: dozens of fields where per-keystroke re-renders are costly
- Integration with non-React code: third-party libraries that manage their own DOM
- File inputs: always uncontrolled
Side-by-Side Comparison
+--------------------+---------------------------+---------------------------+
| | Controlled | Uncontrolled |
+--------------------+---------------------------+---------------------------+
| Source of truth | React state | DOM |
| Value prop | value={state} | defaultValue={initial} |
| Read value | From state variable | ref.current.value |
| Validation timing | Per keystroke (onChange) | On submit (or onBlur) |
| Re-renders | Every keystroke | Only on explicit setState |
| Input formatting | Yes (intercept onChange) | No |
| File inputs | Not possible | Yes (always uncontrolled) |
| Complexity | More code (state + handler| Less code (ref only) |
| | per field) | |
| Best for | Interactive forms, | Simple forms, large forms |
| | real-time validation | performance-critical |
+--------------------+---------------------------+---------------------------+
Form Libraries
For complex forms, managing controlled state manually becomes tedious. Form libraries handle the boilerplate.
React Hook Form (Uncontrolled by Default)
React Hook Form uses refs internally and minimizes re-renders. It is uncontrolled by default but supports controlled fields.
import { useForm } from 'react-hook-form';
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
name: '',
email: '',
password: '',
},
});
const onSubmit = async (data) => {
console.log(data);
// { name: "Alice", email: "[email protected]", password: "..." }
await submitToAPI(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('name', { required: 'Name is required' })}
placeholder="Name"
/>
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid email format',
},
})}
placeholder="Email"
type="email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
placeholder="Password"
type="password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
Formik (Controlled by Default)
Formik manages form state in React โ controlled by default.
import { Formik, Form, Field, ErrorMessage } from 'formik';
function SignupForm() {
return (
<Formik
initialValues={{ name: '', email: '', password: '' }}
validate={(values) => {
const errors = {};
if (!values.name) errors.name = 'Required';
if (!values.email) errors.email = 'Required';
else if (!/\S+@\S+\.\S+/.test(values.email)) errors.email = 'Invalid email';
if (!values.password) errors.password = 'Required';
else if (values.password.length < 8) errors.password = 'Min 8 characters';
return errors;
}}
onSubmit={async (values, { setSubmitting }) => {
await submitToAPI(values);
setSubmitting(false);
}}
>
{({ isSubmitting }) => (
<Form>
<div>
<Field name="name" placeholder="Name" />
<ErrorMessage name="name" component="span" className="error" />
</div>
<div>
<Field name="email" type="email" placeholder="Email" />
<ErrorMessage name="email" component="span" className="error" />
</div>
<div>
<Field name="password" type="password" placeholder="Password" />
<ErrorMessage name="password" component="span" className="error" />
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</Form>
)}
</Formik>
);
}
Library Comparison
| Feature | React Hook Form | Formik |
|---|---|---|
| Approach | Uncontrolled (refs) | Controlled (state) |
| Re-renders | Minimal (per field, on error) | Every keystroke (entire form) |
| Bundle size (gzipped) | ~9 KB | ~13 KB |
| Validation | Built-in + schema (Zod, Yup) | Custom function + Yup |
| Learning curve | Low | Low |
| TypeScript | Excellent | Good |
| Best for | Performance-critical, large forms | Simpler forms, familiarity |
Validation Patterns
Inline Validation (Controlled)
function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const validate = (value) => {
if (!value) return 'Email is required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format';
return '';
};
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
setError(validate(value));
};
return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
className={error ? 'input-error' : ''}
/>
{error && <p className="error-text">{error}</p>}
</div>
);
}
On-Blur Validation (Hybrid)
Validating on blur is less aggressive than per-keystroke. Show errors only after the user leaves the field.
function FormField({ label, name, validate, type = 'text' }) {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const [touched, setTouched] = useState(false);
const handleBlur = () => {
setTouched(true);
if (validate) {
setError(validate(value));
}
};
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (touched) setError(validate ? validate(e.target.value) : '');
}}
onBlur={handleBlur}
className={touched && error ? 'input-error' : ''}
/>
{touched && error && <p className="error-text">{error}</p>}
</div>
);
}
Schema Validation with Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signupSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="Email" type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} placeholder="Password" type="password" />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('confirmPassword')} placeholder="Confirm" type="password" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
Performance for Large Forms
Forms with many fields (20+) can suffer from re-render performance. Each keystroke in a controlled form triggers a re-render of the entire form component.
Problem: Full Form Re-render
// BAD: typing in any field re-renders ALL fields
function LargeForm() {
const [values, setValues] = useState({
field1: '', field2: '', field3: '',
// ... 50 more fields
});
const handleChange = (name) => (e) => {
setValues(prev => ({ ...prev, [name]: e.target.value }));
// This triggers re-render of EVERY field
};
return (
<form>
<input value={values.field1} onChange={handleChange('field1')} />
<input value={values.field2} onChange={handleChange('field2')} />
{/* ... 50 more inputs all re-render */}
</form>
);
}
Solution 1: Isolate State Per Field
function IsolatedField({ name, label }) {
// Each field manages its own state
const [value, setValue] = useState('');
return (
<div>
<label>{label}</label>
<input value={value} onChange={(e) => setValue(e.target.value)} />
</div>
);
}
function LargeForm() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(formRef.current);
const data = Object.fromEntries(formData);
console.log(data);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<IsolatedField name="field1" label="Field 1" />
<IsolatedField name="field2" label="Field 2" />
{/* Each field re-renders independently */}
<button type="submit">Submit</button>
</form>
);
}
Solution 2: Use React Hook Form
React Hook Form is built specifically for this problem. It uses refs internally so typing in one field does not re-render any other field.
function LargeForm() {
const { register, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
{Array.from({ length: 50 }, (_, i) => (
<input key={i} {...register(`field${i}`)} placeholder={`Field ${i}`} />
))}
<button type="submit">Submit</button>
</form>
);
}
// Typing in field 0 does NOT re-render fields 1-49
Solution 3: Debounce State Updates
function DebouncedInput({ name, value, onChange, delay = 300 }) {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => onChange(name, localValue), delay);
return () => clearTimeout(timer);
}, [localValue, delay, name, onChange]);
useEffect(() => {
setLocalValue(value);
}, [value]);
return (
<input
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
/>
);
}
The Mixed Antipattern
Mixing controlled and uncontrolled on the same input is a bug:
// ANTIPATTERN: value without onChange
function Broken() {
// This input cannot be typed into โ React locks it to "hello"
return <input value="hello" />;
}
// ANTIPATTERN: switching between controlled and uncontrolled
function AlsoBroken() {
const [value, setValue] = useState(undefined);
// Initially uncontrolled (value is undefined)
// Then controlled (value becomes a string after first change)
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
// React warns: "A component is changing an uncontrolled input to be controlled"
The fix: always initialize with a string, never undefined.
function Fixed() {
const [value, setValue] = useState(''); // empty string, not undefined
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
Decision Guide
Do you need to:
- Validate per keystroke? --> Controlled
- Format input as user types? --> Controlled
- Conditionally disable submit? --> Controlled
- Show character count live? --> Controlled
Is it:
- A file input? --> Uncontrolled (always)
- A simple form with no validation?--> Uncontrolled (simpler)
- A form with 20+ fields? --> Uncontrolled or React Hook Form
- Integrated with non-React code? --> Uncontrolled
Uncertain?
- Start controlled. Switch to uncontrolled or a library if performance suffers.
Key Takeaways
- Controlled components use
value+onChangeโ React state is the source of truth - Uncontrolled components use
defaultValue+refโ the DOM is the source of truth - File inputs are always uncontrolled
- Controlled enables real-time validation, formatting, and conditional logic
- Uncontrolled is simpler and more performant for large or simple forms
- React Hook Form gives uncontrolled performance with controlled-like features
- Formik is controlled by default, re-renders on every keystroke
- Never mix controlled and uncontrolled on the same input โ initialize state with an empty string, not undefined
- For large forms, isolate state per field or use a form library to avoid full-form re-renders
- Use Zod or Yup for schema-based validation that works with any form approach