Controlled vs Uncontrolled Components in React
Master React form patterns — state vs refs, controlled two-way syncing, uncontrolled FormData, mixing refs for focus, pitfalls, and React 19 useActionState and useFormStatus with production examples.
Introduction
Every React input answers one question: who owns the value? In a controlled component, React state is the source of truth. In an uncontrolled component, the DOM holds the value until you read it. Choosing the wrong model causes subtle bugs — flickering inputs, broken validation, and "A component is changing an uncontrolled input to be controlled" warnings.
This guide covers:
- State vs refs — when each primitive fits
- Controlled components — two-way sync, single state object, validation
- Uncontrolled components —
useRef,FormData, and simple use cases - Mixing state and refs — focus and DOM interaction without fighting data flow
- Pitfalls & anti-patterns — common mistakes and fixes
- React 19 form hooks —
useActionStateanduseFormStatus
Quick index
| # | Section |
|---|---|
| 1 | State vs refs |
| 2 | Controlled components |
| 3 | Uncontrolled components |
| 4 | Mixing state and refs |
| 5 | Decision guide |
| 6 | Pitfalls & anti-patterns |
| 7 | React 19: useActionState & useFormStatus |
1. State vs refs
Both hooks persist values across renders — but they behave very differently for forms.
State (useState) | Refs (useRef) | |
|---|---|---|
| Purpose | Dynamic data management | DOM access & persistent values |
| Source of truth | React state | DOM (for inputs) or .current |
| Re-renders | Yes — every update triggers render | No — mutating .current is silent |
| Reactive UI | ✅ UI always reflects state | ❌ UI does not update when ref changes |
| Typical API | value + onChange | ref + defaultValue / read on submit |
State — reactive source of truth
'use client'
import { useState } from 'react'
function EmailField() {
const [email, setEmail] = useState('')
return <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
}Every keystroke updates state → React re-renders → input shows the new value.
Refs — persistent handle to the DOM
'use client'
import { useRef } from 'react'
function EmailFieldUncontrolled() {
const inputRef = useRef<HTMLInputElement>(null)
function logValue() {
console.log(inputRef.current?.value) // read DOM directly
}
return <input ref={inputRef} defaultValue="" placeholder="you@example.com" onBlur={logValue} />
}Typing updates the DOM internally — React does not re-render on each keypress.
2. Controlled components
In a controlled input, value is always driven by React state and changes flow through onChange. The component is fully declarative — you describe the value for each render.
Two-way data syncing
User types → onChange → setState → re-render → value prop updates input'use client'
import { useState } from 'react'
function SearchBox() {
const [query, setQuery] = useState('')
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search" />
<p className="text-sm text-neutral-500">{query.length} characters</p>
</div>
)
}The character count proves why controlled fits here — the UI depends on live state.
Single state object — multiple fields
For forms with several inputs, one object beats a dozen useState calls.
'use client'
import { useState, type ChangeEvent } from 'react'
type FormState = {
name: string
email: string
role: string
}
const initial: FormState = { name: '', email: '', role: 'developer' }
function ProfileForm() {
const [form, setForm] = useState<FormState>(initial)
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = e.target
setForm((prev) => ({ ...prev, [name]: value }))
}
function handleReset() {
setForm(initial)
}
return (
<form onSubmit={(e) => e.preventDefault()}>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<select name="role" value={form.role} onChange={handleChange}>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
<button type="button" onClick={handleReset}>
Reset
</button>
</form>
)
}Complex form validation
Controlled state makes validation synchronous with render — show errors as the user types or on blur.
'use client'
import { useMemo, useState } from 'react'
function SignupForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [touched, setTouched] = useState({ email: false, password: false })
const errors = useMemo(() => {
const next: Record<string, string> = {}
if (touched.email && !email.includes('@')) next.email = 'Valid email required'
if (touched.password && password.length < 8) next.password = 'Minimum 8 characters'
return next
}, [email, password, touched])
const canSubmit = email.includes('@') && password.length >= 8
return (
<form onSubmit={(e) => e.preventDefault()}>
<div>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
aria-invalid={!!errors.email}
/>
{errors.email && <p className="text-sm text-red-600">{errors.email}</p>}
</div>
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, password: true }))}
aria-invalid={!!errors.password}
/>
{errors.password && <p className="text-sm text-red-600">{errors.password}</p>}
</div>
<button type="submit" disabled={!canSubmit}>
Sign up
</button>
</form>
)
}3. Uncontrolled components
Uncontrolled inputs let the DOM own the value. React sets an initial value with defaultValue (not value) and reads the result when needed — typically on submit.
Implementation with useRef
'use client'
import { useRef } from 'react'
function ContactForm() {
const nameRef = useRef<HTMLInputElement>(null)
const messageRef = useRef<HTMLTextAreaElement>(null)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const payload = {
name: nameRef.current?.value ?? '',
message: messageRef.current?.value ?? '',
}
console.log(payload)
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} name="name" defaultValue="" placeholder="Your name" />
<textarea ref={messageRef} name="message" defaultValue="" placeholder="Message" />
<button type="submit">Send</button>
</form>
)
}Implementation with native FormData
No refs required — the browser collects named fields on submit.
'use client'
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
const subscribe = formData.get('subscribe') === 'on'
await fetch('/api/newsletter', {
method: 'POST',
body: JSON.stringify({ email, subscribe }),
headers: { 'Content-Type': 'application/json' },
})
}
function NewsletterForm() {
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" placeholder="you@example.com" required />
<label>
<input name="subscribe" type="checkbox" defaultChecked />
Weekly digest
</label>
<button type="submit">Subscribe</button>
</form>
)
}Best use cases for uncontrolled
| Use case | Why uncontrolled works |
|---|---|
| Tiny static forms | One or two fields, validate only on submit |
| Quick prototypes / MVPs | Less boilerplate, ship faster |
| Newsletter signups | Single email field, no live UI dependencies |
| File inputs | <input type="file"> is naturally uncontrolled |
| Third-party widgets | Date pickers and rich editors often manage their own DOM |
// File input — almost always uncontrolled
function AvatarUpload() {
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) uploadAvatar(file)
}
return <input type="file" accept="image/*" onChange={handleChange} />
}4. Mixing state and refs
Controlled data and refs solve different problems. A solid pattern: state owns values, refs handle imperative DOM behavior.
| Concern | Tool |
|---|---|
| Input value, validation, disabled logic | useState |
| Focus, select text, scroll into view, measure width | useRef |
Focus behavior after validation error
'use client'
import { useRef, useState } from 'react'
function LoginForm() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const emailRef = useRef<HTMLInputElement>(null)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.includes('@')) {
setError('Enter a valid email')
emailRef.current?.focus() // imperative DOM — refs shine here
emailRef.current?.select()
return
}
setError(null)
// submit…
}
return (
<form onSubmit={handleSubmit}>
<input
ref={emailRef}
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<p id="email-error" className="text-sm text-red-600">
{error}
</p>
)}
<button type="submit">Sign in</button>
</form>
)
}Auto-focus first field on mount
'use client'
import { useEffect, useRef } from 'react'
function WizardStep() {
const firstFieldRef = useRef<HTMLInputElement>(null)
useEffect(() => {
firstFieldRef.current?.focus()
}, [])
return <input ref={firstFieldRef} name="company" defaultValue="" />
}5. Decision guide
| Requirement | Choose |
|---|---|
| Live validation, character count, conditional fields | Controlled |
| Submit-only validation, 1–3 fields | Uncontrolled |
| Reset form to initial values programmatically | Controlled (or remount with key) |
| File upload, native picker widgets | Uncontrolled |
Server Action / form POST with FormData | Uncontrolled (often) |
| Focus management after error | Controlled value + ref for focus |
| 50+ fields, performance sensitive | Uncontrolled or React Hook Form |
// Remount trick — reset uncontrolled form without state
function QuickResetDemo() {
const [formKey, setFormKey] = useState(0)
return (
<>
<form key={formKey} onSubmit={(e) => e.preventDefault()}>
<input name="q" defaultValue="" />
</form>
<button type="button" onClick={() => setFormKey((k) => k + 1)}>
Clear
</button>
</>
)
}6. Pitfalls & anti-patterns
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| Unnecessary mixing | Same field updated via state and ref reads | Pick one owner for the value |
| Controlled ↔ uncontrolled switch | value={email} where email starts as undefined | Initialize useState('') — never undefined |
| Large uncontrolled forms | No per-field validation, hard to show inline errors | Move to controlled or use a form library |
| Over-engineering tiny forms | useReducer + 5 hooks for one email input | Single uncontrolled field + FormData |
value without onChange | Read-only input, user cannot type | Add onChange or switch to defaultValue |
Resetting with defaultValue only | Changing defaultValue after mount does nothing | Controlled reset or remount with key |
The classic warning
Warning: A component is changing an uncontrolled input to be controlled.This happens when value goes from undefined → "text". Always initialize:
// ❌
const [name, setName] = useState<string | undefined>(undefined)
<input value={name} onChange={…} />
// ✅
const [name, setName] = useState('')
<input value={name} onChange={…} />7. React 19: useActionState & useFormStatus
React 19 adds first-class form hooks that pair naturally with uncontrolled Server Actions — less client state, progressive enhancement.
useActionState — action result in state
Replaces the older useFormState name. Tracks pending state and returned errors from a server action.
// actions.ts
'use server'
export async function subscribeAction(_prev: { error?: string } | null, formData: FormData) {
const email = formData.get('email') as string
if (!email?.includes('@')) return { error: 'Invalid email' }
await saveSubscriber(email)
return { error: undefined }
}'use client'
import { useActionState } from 'react'
import { subscribeAction } from './actions'
function NewsletterSignup() {
const [state, formAction, pending] = useActionState(subscribeAction, null)
return (
<form action={formAction}>
<input name="email" type="email" placeholder="you@example.com" required />
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Subscribing…' : 'Subscribe'}
</button>
</form>
)
}Fields stay uncontrolled — the action receives FormData. Client state only holds the action result, not every keystroke.
useFormStatus — pending state in child buttons
Must be called from a component inside the <form> — typically a submit button component.
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Saving…' : label}
</button>
)
}
function SettingsForm({ action }: { action: (formData: FormData) => Promise<void> }) {
return (
<form action={action}>
<input name="displayName" defaultValue="" />
<SubmitButton label="Save settings" />
</form>
)
}| Hook | Purpose | Fits with |
|---|---|---|
useActionState | Server/client action feedback, errors, pending | Uncontrolled + FormData |
useFormStatus | Disable submit / show spinner in child | <form action={…}> |
Summary
| Pattern | Source of truth | Best for |
|---|---|---|
| Controlled | React state (value + onChange) | Validation, dynamic UI, resets |
| Uncontrolled | DOM (defaultValue, FormData, refs) | Simple forms, files, Server Actions |
| Mixed | State for values, refs for DOM | Focus, select, measure after errors |
Controlled and uncontrolled are complementary — not competing religions. Match ownership to how often the UI needs the value, then layer React 19 form hooks when posting to the server without client-side mirrors.
Next reads: Clean React Forms Pattern for useForm + Form compound components, Container-Presenter Pattern for splitting form containers from presenters, and React Design Patterns for the full pattern roadmap.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime