Skip to content

Forms: Controlled vs Uncontrolled

intermediate12 min read

Two Philosophies of Form State

React gives you two fundamentally different approaches to forms:

  1. Controlled: React state is the single source of truth. The input value is driven by state, and every change flows through an event handler.
  2. Uncontrolled: The DOM is the source of truth. You read values with refs when you need them (typically on submit).
// Controlled — React owns the value
function ControlledInput() {
  const [value, setValue] = useState('');
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

// Uncontrolled — DOM owns the value
function UncontrolledInput() {
  const inputRef = useRef(null);
  function handleSubmit() {
    console.log(inputRef.current.value); // Read from DOM
  }
  return <input ref={inputRef} defaultValue="" />;
}
Mental Model

Think of controlled inputs as puppet mode — React pulls every string, and the input can only show what React tells it to show. Think of uncontrolled inputs as freeform mode — the input manages itself, and React peeks at the result when needed. Puppet mode gives total control at the cost of more code. Freeform mode is simpler but React cannot validate or transform input in real time.

Controlled Components In Depth

In a controlled component, the React state is the single source of truth:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    login(email, password);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

Why the Input Freezes Without onChange

If you set value without an onChange, the input becomes read-only:

// This input cannot be typed into:
<input value="frozen" />

// React overwrites the DOM value on every render.
// User types → DOM updates → React re-renders → value is set back to "frozen".

React forces the DOM input value to match the state value after every render. Without an onChange handler to update state, the state never changes, and the input appears frozen.

Common Trap

Setting value={undefined} makes a controlled input behave as uncontrolled (no value control). This can happen accidentally when state is undefined on mount. If you see an input that starts editable but becomes frozen, check if the initial state transitions from undefined to a string — React switches from uncontrolled to controlled mode and logs a warning.

Real-Time Validation

Controlled inputs enable validation on every keystroke:

function CreditCardInput() {
  const [card, setCard] = useState('');
  const [error, setError] = useState('');

  function handleChange(e) {
    const raw = e.target.value.replace(/\D/g, ''); // Strip non-digits
    const formatted = raw.replace(/(\d{4})/g, '$1 ').trim(); // Add spaces
    setCard(formatted);

    if (raw.length > 0 && raw.length < 16) {
      setError('Card number must be 16 digits');
    } else {
      setError('');
    }
  }

  return (
    <div>
      <input value={card} onChange={handleChange} maxLength={19} />
      {error && <span className="error">{error}</span>}
    </div>
  );
}

This transforms user input in real time — impossible with uncontrolled inputs.

Uncontrolled Components

Uncontrolled components use defaultValue (not value) and read the DOM when needed:

function FileUploadForm() {
  const fileRef = useRef(null);
  const nameRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData();
    formData.append('name', nameRef.current.value);
    formData.append('file', fileRef.current.files[0]);
    upload(formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="" placeholder="File name" />
      <input ref={fileRef} type="file" />
      <button type="submit">Upload</button>
    </form>
  );
}
Info

File inputs (<input type="file" />) are always uncontrolled in React. You cannot set their value programmatically due to browser security restrictions. Use refs to read the selected files.

The FormData API Approach

Modern React patterns increasingly use the native FormData API, which works well with both controlled and uncontrolled inputs:

function ContactForm() {
  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);
    // data = { name: 'Alice', email: 'alice@example.com', message: '...' }
    submitForm(data);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}
React 19 Server Actions and forms

React 19 introduces the action prop on forms, which works with Server Actions in Next.js:

async function createUser(formData) {
  'use server';
  const name = formData.get('name');
  // Server-side processing
}

function SignupForm() {
  return (
    <form action={createUser}>
      <input name="name" required />
      <button type="submit">Sign Up</button>
    </form>
  );
}

The action prop accepts a function that receives FormData. Combined with useActionState, this provides built-in pending states, error handling, and optimistic updates. This pattern favors uncontrolled inputs with name attributes over controlled state.

When to Use Each

FeatureControlledUncontrolled
Real-time validationYesNo
Input formatting/maskingYesNo
Conditional field disablingEasyHarder
Performance (many fields)Re-render per keystrokeNo re-renders
Form library integrationWorks with allWorks with some
File inputsCannotMust use
Submit-only validationOverkillIdeal
TestingState-based assertionsDOM queries

Production Scenario: Dynamic Form with Validation

function RegistrationForm() {
  const [form, setForm] = useState({
    username: '',
    email: '',
    password: '',
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  function updateField(field, value) {
    setForm(prev => ({ ...prev, [field]: value }));
    if (touched[field]) {
      validateField(field, value);
    }
  }

  function handleBlur(field) {
    setTouched(prev => ({ ...prev, [field]: true }));
    validateField(field, form[field]);
  }

  function validateField(field, value) {
    const fieldErrors = { ...errors };
    switch (field) {
      case 'username':
        fieldErrors.username = value.length < 3 ? 'At least 3 characters' : '';
        break;
      case 'email':
        fieldErrors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : 'Invalid email';
        break;
      case 'password':
        fieldErrors.password = value.length < 8 ? 'At least 8 characters' : '';
        break;
    }
    setErrors(fieldErrors);
  }

  return (
    <form>
      {['username', 'email', 'password'].map(field => (
        <div key={field}>
          <input
            type={field === 'password' ? 'password' : 'text'}
            value={form[field]}
            onChange={(e) => updateField(field, e.target.value)}
            onBlur={() => handleBlur(field)}
          />
          {touched[field] && errors[field] && (
            <span className="error">{errors[field]}</span>
          )}
        </div>
      ))}
    </form>
  );
}
Execution Trace
Keystroke:
User types 'A' in controlled input
DOM input fires native input event
Handler:
onChange fires, calls setValue('A')
State update queued
Render:
Component re-renders with value='A'
New React element: `<input value='A' />`
Commit:
React sets input.value = 'A' in DOM
DOM value synchronized with state
Display:
User sees 'A' in the input
Complete round trip per keystroke
Common Mistakes
  • Wrong: Setting value without onChange — creating a read-only input Right: Always pair value with onChange, or use defaultValue for uncontrolled

  • Wrong: Switching between controlled and uncontrolled (value goes from undefined to string) Right: Initialize state to an empty string, not undefined

  • Wrong: Using state for every input in a large form (dozens of fields) Right: Consider uncontrolled with FormData, or a form library like React Hook Form

  • Wrong: Trying to set value on file inputs Right: File inputs are always uncontrolled. Use refs to read files.

Quiz
What happens if you render <input value='hello' /> without an onChange handler?
Quiz
Which input type MUST be uncontrolled in React?
Quiz
What is the initial mode of this input: <input value={data} /> where data starts as undefined?
Key Rules
  1. 1Controlled: React state is truth. value + onChange always paired. Re-renders on every keystroke.
  2. 2Uncontrolled: DOM is truth. defaultValue + ref. Read on submit. No re-renders during typing.
  3. 3File inputs are always uncontrolled — browser security prevents setting their value.
  4. 4Never switch between controlled and uncontrolled — initialize state to '' not undefined.
  5. 5For large forms, consider uncontrolled with FormData or a library like React Hook Form.

Challenge: Build a Controlled Select

Controlled Multi-Select