Episode 2 — React Frontend Architecture NextJS / 2.5 — Event Handling and Conditional Rendering
2.5.b — Controlled Inputs
In one sentence: In React, controlled inputs bind a form element's value directly to component state via
value+onChange, giving you complete programmatic control over every keystroke, validation rule, and formatting decision.
Navigation: ← Handling Click Events · Next → Conditional Rendering Patterns
Table of Contents
- What is a Controlled Input?
- Uncontrolled vs Controlled — The Mental Model
- Controlled Text Input
- The onChange Event in Detail
- Controlled Textarea
- Controlled Select (Dropdown)
- Controlled Checkbox
- Controlled Radio Buttons
- Controlled Number Input
- Controlled Range (Slider)
- Controlled File Input — The Exception
- Multi-Field Forms with a Single Handler
- Input Formatting and Masking
- Real-Time Validation
- Common Mistakes and Anti-Patterns
- When to Use Uncontrolled Instead
- Key Takeaways
1. What is a Controlled Input?
A controlled input is a form element whose value is driven by React state. The component is the "single source of truth" — the DOM input merely reflects what state says.
┌──────────────────────────────────────────────────┐
│ React Component │
│ │
│ state: { name: "Alice" } │
│ │ │
│ │ value={name} ┌──────────────────┐ │
│ └────────────────► │ <input> │ │
│ │ displays "Alice" │ │
│ ┌──────────────── │ user types "B" │ │
│ │ onChange(e) └──────────────────┘ │
│ │ │
│ ▼ │
│ setName(e.target.value) │
│ state: { name: "AliceB" } │
│ │ │
│ │ value={name} │
│ └──► input now shows "AliceB" │
│ │
└──────────────────────────────────────────────────┘
The loop:
- State holds the value
- Input displays the value
- User types →
onChangefires - Handler calls
setStatewith new value - React re-renders → input shows updated value
- Go to step 3
2. Uncontrolled vs Controlled — The Mental Model
Uncontrolled Input (DOM is the source of truth)
function UncontrolledForm() {
const inputRef = useRef(null);
function handleSubmit(event) {
event.preventDefault();
// Read value directly from DOM
console.log(inputRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
{/* DOM manages its own state */}
<input ref={inputRef} defaultValue="initial" />
<button type="submit">Submit</button>
</form>
);
}
Controlled Input (React state is the source of truth)
function ControlledForm() {
const [value, setValue] = useState('initial');
function handleSubmit(event) {
event.preventDefault();
// Value is already in state
console.log(value);
}
return (
<form onSubmit={handleSubmit}>
{/* React controls the value */}
<input value={value} onChange={(e) => setValue(e.target.value)} />
<button type="submit">Submit</button>
</form>
);
}
Side-by-Side Comparison
| Aspect | Uncontrolled | Controlled |
|---|---|---|
| Source of truth | DOM | React state |
| Access value | ref.current.value | state variable |
| Set initial value | defaultValue | useState('initial') |
| Real-time validation | Hard | Easy |
| Conditional disable | Hard | disabled={condition} |
| Format as you type | Very hard | Easy |
| Instant feedback | Manual DOM query | Automatic re-render |
| Reset form | ref.current.value = '' | setValue('') |
| When to use | File inputs, simple one-offs | Almost everything else |
3. Controlled Text Input
Basic Pattern
function TextInput() {
const [name, setName] = useState('');
return (
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
<p>Hello, {name || 'stranger'}!</p>
</div>
);
}
With Character Limit
function LimitedInput({ maxLength = 50 }) {
const [text, setText] = useState('');
function handleChange(event) {
const newValue = event.target.value;
if (newValue.length <= maxLength) {
setText(newValue);
}
// If over limit, simply don't update — input stays the same
}
return (
<div>
<input
type="text"
value={text}
onChange={handleChange}
placeholder={`Max ${maxLength} characters`}
/>
<span style={{ color: text.length > maxLength * 0.8 ? 'red' : '#999' }}>
{text.length}/{maxLength}
</span>
</div>
);
}
Uppercase-Only Input
function UppercaseInput() {
const [value, setValue] = useState('');
function handleChange(event) {
// Transform on every keystroke
setValue(event.target.value.toUpperCase());
}
return <input value={value} onChange={handleChange} placeholder="AUTO UPPERCASE" />;
}
4. The onChange Event in Detail
What onChange Actually Fires For
In HTML, change fires when the element loses focus. In React, onChange fires on every keystroke (like the native input event). This is a React-specific behavior.
function ChangeVsInput() {
return (
<div>
{/* React onChange fires on EVERY keystroke */}
<input
onChange={(e) => console.log('React onChange:', e.target.value)}
/>
{/* To get native "change" behavior (on blur), use onBlur */}
</div>
);
}
The Event Object for Inputs
function InputEventDetails() {
function handleChange(event) {
console.log(event.target.value); // Current input value
console.log(event.target.name); // Input's "name" attribute
console.log(event.target.type); // "text", "email", etc.
console.log(event.target.checked); // For checkboxes
console.log(event.target.selectedOptions); // For selects (multiple)
}
return <input name="email" type="email" onChange={handleChange} />;
}
5. Controlled Textarea
In HTML, textarea content goes between tags: <textarea>Hello</textarea>.
In React, textarea uses value prop just like an input.
function ControlledTextarea() {
const [bio, setBio] = useState('');
return (
<div>
<label htmlFor="bio">Bio:</label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={5}
cols={40}
placeholder="Tell us about yourself..."
style={{ resize: 'vertical' }}
/>
<p>{bio.length} characters | ~{Math.ceil(bio.split(/\s+/).filter(Boolean).length)} words</p>
</div>
);
}
Auto-Resizing Textarea
function AutoResizeTextarea() {
const [value, setValue] = useState('');
const textareaRef = useRef(null);
function handleChange(event) {
setValue(event.target.value);
// Auto-resize
const textarea = textareaRef.current;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
return (
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
style={{
width: '100%',
minHeight: 40,
overflow: 'hidden',
resize: 'none',
}}
placeholder="Type and I'll grow..."
/>
);
}
6. Controlled Select (Dropdown)
Single Select
function CountrySelect() {
const [country, setCountry] = useState('');
const countries = [
{ code: 'us', name: 'United States' },
{ code: 'uk', name: 'United Kingdom' },
{ code: 'in', name: 'India' },
{ code: 'de', name: 'Germany' },
{ code: 'jp', name: 'Japan' },
];
return (
<div>
<label htmlFor="country">Country:</label>
<select
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
>
<option value="">-- Select a country --</option>
{countries.map(c => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
{country && <p>Selected: {countries.find(c => c.code === country)?.name}</p>}
</div>
);
}
Multiple Select
function MultiSelect() {
const [selected, setSelected] = useState([]);
function handleChange(event) {
const options = Array.from(event.target.selectedOptions);
setSelected(options.map(o => o.value));
}
return (
<div>
<select multiple value={selected} onChange={handleChange} size={5}>
<option value="react">React</option>
<option value="vue">Vue</option>
<option value="angular">Angular</option>
<option value="svelte">Svelte</option>
<option value="solid">Solid</option>
</select>
<p>Selected: {selected.join(', ') || 'none'}</p>
</div>
);
}
7. Controlled Checkbox
Single Checkbox (Boolean)
function AgreeCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<div>
<label>
<input
type="checkbox"
checked={agreed} // Note: "checked", not "value"
onChange={(e) => setAgreed(e.target.checked)}
/>
{' '}I agree to the terms and conditions
</label>
<button disabled={!agreed}>Continue</button>
</div>
);
}
Multiple Checkboxes (Array of Selected Values)
function InterestsCheckboxes() {
const [interests, setInterests] = useState([]);
const allInterests = ['React', 'Vue', 'Angular', 'Svelte', 'Next.js'];
function handleChange(event) {
const { value, checked } = event.target;
setInterests(prev =>
checked
? [...prev, value] // Add
: prev.filter(i => i !== value) // Remove
);
}
return (
<fieldset>
<legend>Select your interests:</legend>
{allInterests.map(interest => (
<label key={interest} style={{ display: 'block' }}>
<input
type="checkbox"
value={interest}
checked={interests.includes(interest)}
onChange={handleChange}
/>
{' '}{interest}
</label>
))}
<p>Selected: {interests.join(', ') || 'none'}</p>
</fieldset>
);
}
8. Controlled Radio Buttons
function PlanSelector() {
const [plan, setPlan] = useState('free');
const plans = [
{ id: 'free', name: 'Free', price: '$0/mo', features: 'Basic features' },
{ id: 'pro', name: 'Pro', price: '$9/mo', features: 'All features' },
{ id: 'enterprise', name: 'Enterprise', price: '$29/mo', features: 'Everything + support' },
];
return (
<fieldset>
<legend>Choose a plan:</legend>
{plans.map(p => (
<label
key={p.id}
style={{
display: 'block',
padding: 12,
margin: 4,
border: `2px solid ${plan === p.id ? '#0070f3' : '#eee'}`,
borderRadius: 8,
cursor: 'pointer',
}}
>
<input
type="radio"
name="plan"
value={p.id}
checked={plan === p.id}
onChange={(e) => setPlan(e.target.value)}
/>
{' '}<strong>{p.name}</strong> — {p.price} ({p.features})
</label>
))}
<p>Current plan: <strong>{plans.find(p => p.id === plan)?.name}</strong></p>
</fieldset>
);
}
9. Controlled Number Input
function QuantityPicker({ min = 1, max = 99 }) {
const [quantity, setQuantity] = useState(1);
function handleChange(event) {
const raw = event.target.value;
// Allow empty string while typing
if (raw === '') {
setQuantity('');
return;
}
const num = parseInt(raw, 10);
if (!isNaN(num) && num >= min && num <= max) {
setQuantity(num);
}
}
function handleBlur() {
// Reset to min if empty on blur
if (quantity === '' || quantity < min) setQuantity(min);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={() => setQuantity(q => Math.max(min, q - 1))}
disabled={quantity <= min}
>
−
</button>
<input
type="number"
value={quantity}
onChange={handleChange}
onBlur={handleBlur}
min={min}
max={max}
style={{ width: 60, textAlign: 'center' }}
/>
<button
onClick={() => setQuantity(q => Math.min(max, q + 1))}
disabled={quantity >= max}
>
+
</button>
</div>
);
}
10. Controlled Range (Slider)
function BrightnessSlider() {
const [brightness, setBrightness] = useState(50);
return (
<div>
<label htmlFor="brightness">
Brightness: {brightness}%
</label>
<input
id="brightness"
type="range"
min={0}
max={100}
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
/>
<div
style={{
width: 100,
height: 100,
background: `hsl(0, 0%, ${brightness}%)`,
border: '1px solid #ccc',
}}
/>
</div>
);
}
Dual Range Slider
function PriceRange() {
const [min, setMin] = useState(20);
const [max, setMax] = useState(80);
return (
<div>
<p>Price: ${min} — ${max}</p>
<div>
<label>Min: ${min}</label>
<input
type="range"
min={0}
max={100}
value={min}
onChange={(e) => {
const val = Number(e.target.value);
if (val < max) setMin(val);
}}
/>
</div>
<div>
<label>Max: ${max}</label>
<input
type="range"
min={0}
max={100}
value={max}
onChange={(e) => {
const val = Number(e.target.value);
if (val > min) setMax(val);
}}
/>
</div>
</div>
);
}
11. Controlled File Input — The Exception
File inputs cannot be controlled. Their value is read-only for security. You must use uncontrolled (ref) or just read on change.
function FileUpload() {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
function handleChange(event) {
const selectedFile = event.target.files[0];
if (!selectedFile) return;
setFile(selectedFile);
// Create preview for images
if (selectedFile.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => setPreview(e.target.result);
reader.readAsDataURL(selectedFile);
}
}
function handleClear() {
setFile(null);
setPreview(null);
// Can't reset input value — use a key to remount it
}
return (
<div>
{/* key={file?.name} forces remount to clear the input */}
<input
key={file?.name || 'empty'}
type="file"
accept="image/*"
onChange={handleChange}
/>
{file && (
<div>
<p>{file.name} ({(file.size / 1024).toFixed(1)} KB)</p>
{preview && <img src={preview} alt="Preview" style={{ maxWidth: 200 }} />}
<button onClick={handleClear}>Clear</button>
</div>
)}
</div>
);
}
12. Multi-Field Forms with a Single Handler
Instead of one handler per field, use the input's name attribute:
function RegistrationForm() {
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
role: 'developer',
newsletter: false,
});
// ONE handler for all fields
function handleChange(event) {
const { name, value, type, checked } = event.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
function handleSubmit(event) {
event.preventDefault();
console.log('Form data:', form);
}
return (
<form onSubmit={handleSubmit}>
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
placeholder="First name"
/>
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
placeholder="Last name"
/>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>
<select name="role" value={form.role} onChange={handleChange}>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
<label>
<input
name="newsletter"
type="checkbox"
checked={form.newsletter}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
<button type="submit">Register</button>
</form>
);
}
Why This Pattern Works
// The magic is computed property names:
const name = 'email';
const value = 'alice@example.com';
// This:
{ [name]: value }
// Becomes:
{ email: 'alice@example.com' }
// Combined with spread:
{ ...prev, [name]: value }
// Becomes:
{ firstName: 'Alice', lastName: 'Smith', email: 'alice@example.com', ... }
13. Input Formatting and Masking
Phone Number Formatting
function PhoneInput() {
const [phone, setPhone] = useState('');
function formatPhone(value) {
// Remove all non-digits
const digits = value.replace(/\D/g, '');
// Format: (XXX) XXX-XXXX
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
function handleChange(event) {
setPhone(formatPhone(event.target.value));
}
return (
<input
type="tel"
value={phone}
onChange={handleChange}
placeholder="(555) 123-4567"
/>
);
}
Credit Card Number Formatting
function CreditCardInput() {
const [card, setCard] = useState('');
function formatCard(value) {
const digits = value.replace(/\D/g, '').slice(0, 16);
// Add space every 4 digits: "1234 5678 9012 3456"
return digits.replace(/(.{4})/g, '$1 ').trim();
}
function handleChange(event) {
setCard(formatCard(event.target.value));
}
return (
<input
type="text"
value={card}
onChange={handleChange}
placeholder="1234 5678 9012 3456"
maxLength={19}
/>
);
}
Currency Input
function CurrencyInput() {
const [amount, setAmount] = useState('');
function handleChange(event) {
let value = event.target.value;
// Allow only digits and one decimal point
value = value.replace(/[^0-9.]/g, '');
// Prevent multiple decimal points
const parts = value.split('.');
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('');
}
// Limit to 2 decimal places
if (parts[1] && parts[1].length > 2) {
value = parts[0] + '.' + parts[1].slice(0, 2);
}
setAmount(value);
}
return (
<div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: 12, top: 8 }}>$</span>
<input
type="text"
value={amount}
onChange={handleChange}
placeholder="0.00"
style={{ paddingLeft: 24 }}
/>
</div>
);
}
14. Real-Time Validation
Field-Level Validation
function ValidatedInput() {
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const showError = touched && !isValid && email.length > 0;
return (
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
style={{
border: `2px solid ${showError ? 'red' : touched && isValid ? 'green' : '#ccc'}`,
borderRadius: 4,
padding: '8px 12px',
}}
placeholder="your@email.com"
/>
{showError && (
<p style={{ color: 'red', fontSize: 12, margin: '4px 0 0' }}>
Please enter a valid email address
</p>
)}
{touched && isValid && (
<p style={{ color: 'green', fontSize: 12, margin: '4px 0 0' }}>
✓ Valid email
</p>
)}
</div>
);
}
Password Strength Meter
function PasswordInput() {
const [password, setPassword] = useState('');
function getStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return score;
}
const strength = getStrength(password);
const labels = ['', 'Very Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
const colors = ['#ccc', '#ff4444', '#ff8800', '#ffcc00', '#88cc00', '#00cc44', '#00aa44'];
return (
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
/>
{password && (
<div>
<div style={{
height: 4,
borderRadius: 2,
background: '#eee',
marginTop: 8,
}}>
<div style={{
height: '100%',
width: `${(strength / 6) * 100}%`,
background: colors[strength],
borderRadius: 2,
transition: 'all 0.3s',
}} />
</div>
<span style={{ fontSize: 12, color: colors[strength] }}>
{labels[strength]}
</span>
<ul style={{ fontSize: 12, margin: '4px 0' }}>
<li style={{ color: password.length >= 8 ? 'green' : '#999' }}>
{password.length >= 8 ? '✓' : '○'} At least 8 characters
</li>
<li style={{ color: /[A-Z]/.test(password) ? 'green' : '#999' }}>
{/[A-Z]/.test(password) ? '✓' : '○'} Uppercase letter
</li>
<li style={{ color: /[a-z]/.test(password) ? 'green' : '#999' }}>
{/[a-z]/.test(password) ? '✓' : '○'} Lowercase letter
</li>
<li style={{ color: /[0-9]/.test(password) ? 'green' : '#999' }}>
{/[0-9]/.test(password) ? '✓' : '○'} Number
</li>
<li style={{ color: /[^A-Za-z0-9]/.test(password) ? 'green' : '#999' }}>
{/[^A-Za-z0-9]/.test(password) ? '✓' : '○'} Special character
</li>
</ul>
</div>
)}
</div>
);
}
15. Common Mistakes and Anti-Patterns
Mistake 1: Missing onChange → Read-Only Input
// ❌ WARNING: This renders a read-only input
// React will warn: "You provided a `value` prop without an `onChange` handler"
<input value={name} />
// ✅ Add onChange handler
<input value={name} onChange={(e) => setName(e.target.value)} />
// ✅ Or use defaultValue for uncontrolled
<input defaultValue={name} />
// ✅ Or explicitly mark as readOnly
<input value={name} readOnly />
Mistake 2: Mixing value and defaultValue
// ❌ WRONG — don't use both
<input value={name} defaultValue="initial" />
// ✅ Controlled — use value + onChange
<input value={name} onChange={handleChange} />
// ✅ Uncontrolled — use defaultValue + ref
<input defaultValue="initial" ref={inputRef} />
Mistake 3: Setting State to undefined/null
// ❌ WRONG — switching from controlled to uncontrolled
const [name, setName] = useState('Alice');
// Later: setName(undefined) → React warns about switching modes
// ✅ CORRECT — always use string
const [name, setName] = useState('');
// To "clear": setName('') — always a string
Mistake 4: Not Using name Attribute
// ❌ TEDIOUS — one handler per field
function handleFirstNameChange(e) { setFirstName(e.target.value); }
function handleLastNameChange(e) { setLastName(e.target.value); }
function handleEmailChange(e) { setEmail(e.target.value); }
// ✅ BETTER — one handler, use name
function handleChange(e) {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
}
Mistake 5: Checkbox value vs checked
// ❌ WRONG — using value for checkbox state
<input type="checkbox" value={isChecked} onChange={handleChange} />
// ✅ CORRECT — use checked for checkbox
<input type="checkbox" checked={isChecked} onChange={(e) => setIsChecked(e.target.checked)} />
16. When to Use Uncontrolled Instead
Sometimes controlled inputs are overkill:
| Use Case | Recommended | Why |
|---|---|---|
| File upload | Uncontrolled | value is read-only on file inputs |
| Very large forms (50+ fields) | Uncontrolled + FormData | Avoids massive re-render on each keystroke |
| Third-party DOM components | Uncontrolled + ref | The library manages its own state |
| Simple fire-and-forget | Uncontrolled | No need to track intermediate state |
| Real-time validation needed | Controlled | You need access to current value |
| Formatting as user types | Controlled | Must intercept and transform |
| Conditional field logic | Controlled | Need state to drive visibility |
The FormData Alternative
function UncontrolledBigForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = Object.fromEntries(formData);
console.log(data);
// { firstName: "Alice", lastName: "Smith", email: "alice@x.com" }
}
return (
<form onSubmit={handleSubmit}>
<input name="firstName" placeholder="First name" />
<input name="lastName" placeholder="Last name" />
<input name="email" type="email" placeholder="Email" />
<button type="submit">Submit</button>
</form>
);
}
17. Key Takeaways
- Controlled inputs use
value+onChange— React is the single source of truth - Uncontrolled inputs use
defaultValue+ref— DOM is the source of truth - React's
onChangefires on every keystroke, unlike HTML's nativechangeevent - Use
checked(notvalue) for checkboxes and radio buttons - File inputs are always uncontrolled — their value is read-only
- Use the
nameattribute + computed property names for multi-field forms with one handler - Formatting (phone, credit card, currency) is trivial with controlled inputs
- Real-time validation works best with controlled inputs +
onBlurfor "touched" state - Never set controlled input value to
undefinedornull— always use a string - Use
defaultValueonly for uncontrolled inputs — never mix withvalue
Explain-It Challenge
-
The Restaurant Analogy: Explain controlled vs uncontrolled inputs using the analogy of a restaurant order. In one scenario, the waiter writes down exactly what you say (controlled). In another, you write your own order on a slip (uncontrolled). How does this relate to who "owns" the data?
-
The Debugging Session: A junior developer writes
<input value={name} />and the input freezes — they can type but nothing appears. Walk through exactly what's happening in React's render cycle and how to fix it. -
The Technical Trade-off: You're building a form with 100 fields. Someone suggests making them all controlled. Explain the performance implications and when uncontrolled + FormData might be the better architectural choice.
Navigation: ← Handling Click Events · Next → Conditional Rendering Patterns