Reactbeginner

Controlled vs Uncontrolled Components

Master React form patterns: controlled inputs with state, uncontrolled with refs, form libraries, validation strategies, and performance considerations for large forms.

12 min readยทPublished Mar 26, 2026
reactformscontrolleduncontrolled

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:

  1. Controlled: React state is the single source of truth. Every keystroke updates state, which updates the input.
  2. 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

FeatureReact Hook FormFormik
ApproachUncontrolled (refs)Controlled (state)
Re-rendersMinimal (per field, on error)Every keystroke (entire form)
Bundle size (gzipped)~9 KB~13 KB
ValidationBuilt-in + schema (Zod, Yup)Custom function + Yup
Learning curveLowLow
TypeScriptExcellentGood
Best forPerformance-critical, large formsSimpler 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

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles