Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsFormsControlled ComponentsUncontrolled Components

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.

Jul 3, 202613 min read

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:

  1. State vs refs — when each primitive fits
  2. Controlled components — two-way sync, single state object, validation
  3. Uncontrolled components — useRef, FormData, and simple use cases
  4. Mixing state and refs — focus and DOM interaction without fighting data flow
  5. Pitfalls & anti-patterns — common mistakes and fixes
  6. React 19 form hooks — useActionState and useFormStatus

Note

Controlled vs uncontrolled is a data-ownership decision, not a team preference. Pick based on validation needs, form size, and whether the UI must react to every keystroke.

Quick index

#Section
1State vs refs
2Controlled components
3Uncontrolled components
4Mixing state and refs
5Decision guide
6Pitfalls & anti-patterns
7React 19: useActionState & useFormStatus

1. State vs refs

Both hooks persist values across renders — but they behave very differently for forms.

State (useState)Refs (useRef)
PurposeDynamic data managementDOM access & persistent values
Source of truthReact stateDOM (for inputs) or .current
Re-rendersYes — every update triggers renderNo — mutating .current is silent
Reactive UI✅ UI always reflects state❌ UI does not update when ref changes
Typical APIvalue + onChangeref + defaultValue / read on submit

State — reactive source of truth

tsx
'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

tsx
'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.

Note

Use state when the UI must react to the value (character count, disabled submit, conditional fields). Use refs when you only need the value at submit time or for imperative DOM actions (focus, scroll, measure).

Back to index


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

plaintext
User types → onChange → setState → re-render → value prop updates input
tsx
'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.

tsx
'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>
  )
}

Tip

Name attribute matching: set name="email" on the input and spread [name]: value in state. One handleChange scales to any field count — declarative and DRY.

Complex form validation

Controlled state makes validation synchronous with render — show errors as the user types or on blur.

tsx
'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>
  )
}

Performance

Controlled forms re-render on every keystroke. For large forms (50+ fields), consider splitting into sub-components with memo, using uncontrolled inputs with submit-time validation, or libraries like React Hook Form that minimize re-renders.

Back to index


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

tsx
'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.

tsx
'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>
  )
}

Tip

FormData + Server Actions (Next.js) is the idiomatic uncontrolled stack — the server receives fields without client-side state mirroring every input.

Best use cases for uncontrolled

Use caseWhy uncontrolled works
Tiny static formsOne or two fields, validate only on submit
Quick prototypes / MVPsLess boilerplate, ship faster
Newsletter signupsSingle email field, no live UI dependencies
File inputs<input type="file"> is naturally uncontrolled
Third-party widgetsDate pickers and rich editors often manage their own DOM
tsx
// 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} />
}

Note

Use defaultValue / defaultChecked for uncontrolled inputs. Never pass value={undefined} and then later value="hello" — that triggers the controlled/uncontrolled warning.

Back to index


4. Mixing state and refs

Controlled data and refs solve different problems. A solid pattern: state owns values, refs handle imperative DOM behavior.

ConcernTool
Input value, validation, disabled logicuseState
Focus, select text, scroll into view, measure widthuseRef

Focus behavior after validation error

tsx
'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

tsx
'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="" />
}

Note

Data via state, interaction via refs — keep values in one place. Do not mirror the same field in both useState and ref.current.value; that creates two sources of truth.

Back to index


5. Decision guide

RequirementChoose
Live validation, character count, conditional fieldsControlled
Submit-only validation, 1–3 fieldsUncontrolled
Reset form to initial values programmaticallyControlled (or remount with key)
File upload, native picker widgetsUncontrolled
Server Action / form POST with FormDataUncontrolled (often)
Focus management after errorControlled value + ref for focus
50+ fields, performance sensitiveUncontrolled or React Hook Form
tsx
// 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>
    </>
  )
}

Tip

When unsure, default to controlled for product forms with validation UX. Default to uncontrolled for marketing capture forms and Server Action endpoints.

Back to index


6. Pitfalls & anti-patterns

Anti-patternWhat goes wrongFix
Unnecessary mixingSame field updated via state and ref readsPick one owner for the value
Controlled ↔ uncontrolled switchvalue={email} where email starts as undefinedInitialize useState('') — never undefined
Large uncontrolled formsNo per-field validation, hard to show inline errorsMove to controlled or use a form library
Over-engineering tiny formsuseReducer + 5 hooks for one email inputSingle uncontrolled field + FormData
value without onChangeRead-only input, user cannot typeAdd onChange or switch to defaultValue
Resetting with defaultValue onlyChanging defaultValue after mount does nothingControlled reset or remount with key

The classic warning

plaintext
Warning: A component is changing an uncontrolled input to be controlled.

This happens when value goes from undefined → "text". Always initialize:

tsx
// ❌
const [name, setName] = useState<string | undefined>(undefined)
<input value={name} onChange={…} />
 
// ✅
const [name, setName] = useState('')
<input value={name} onChange={…} />

Warning

Do not read ref.current.value on every render to drive UI — refs do not trigger re-renders. If the UI must show the value, use controlled state.

Warning

Large uncontrolled multi-step wizards without a form library become unmaintainable — you lose visibility into intermediate values. Use controlled state, a reducer, or React Hook Form for step validation.

Back to index


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.

tsx
// 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 }
}
tsx
'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.

tsx
'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>
  )
}

Note

useFormStatus lives in react-dom, not react. It only works with forms using the action prop (Server Actions or client async functions).

HookPurposeFits with
useActionStateServer/client action feedback, errors, pendingUncontrolled + FormData
useFormStatusDisable submit / show spinner in child<form action={…}>

Interview Answer

"When would you use controlled vs uncontrolled inputs?"

Controlled when the UI must react to input in real time — validation messages, enabling submit, showing previews, or dependent fields. Uncontrolled when you only need values on submit, especially with native FormData and Server Actions. Mix refs with controlled values for focus and accessibility, not as a second source of truth.

Tip

Production checklist

  • Initialize controlled strings as '', not undefined
  • Match name attributes to state keys for multi-field forms
  • Use defaultValue / FormData for simple server-posted forms
  • useActionState for action errors; useFormStatus for submit UI
  • Refs for focus/select — not for mirroring typed values
  • Large validated forms → controlled or React Hook Form, not raw uncontrolled

Back to index


Summary

PatternSource of truthBest for
ControlledReact state (value + onChange)Validation, dynamic UI, resets
UncontrolledDOM (defaultValue, FormData, refs)Simple forms, files, Server Actions
MixedState for values, refs for DOMFocus, 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime