AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Controlled vs Uncontrolled: React Form State Patterns

Last updated:
Controlled vs Uncontrolled: React Form State Patterns

Master React form patterns. Learn when to use controlled vs uncontrolled components, performance implications, and real-world examples with TypeScript.

# Controlled vs Uncontrolled: React Form State Patterns

Every React developer works with forms. Whether you're building a login page, a search bar, or a complex multi-step wizard, understanding how to manage form state is fundamental. React offers two distinct approaches: controlled components where React manages state, and uncontrolled components where the DOM handles it. Choosing the right pattern dramatically affects your application's performance, maintainability, and user experience.

# Table of Contents

  1. Understanding Form State Management
  2. Controlled Components Explained
  3. Uncontrolled Components Explained
  4. Performance Comparison
  5. When to Use Each Pattern
  6. Advanced: Hybrid Approaches
  7. Common Patterns and Pitfalls
  8. FAQ

# Understanding Form State Management

React's philosophy is declarative—you describe what the UI should look like for a given state. With forms, you have two ways to keep your described UI in sync with actual form values.

In a controlled component, React state is the "single source of truth." Every keystroke updates state, and the input's value comes from that state. In an uncontrolled component, the DOM itself holds the value, and React doesn't track it in state until you explicitly ask for it.

The choice between these patterns affects not just code style, but performance, validation timing, and debugging difficulty. Let's explore both deeply.

# Controlled Components Explained

# The Controlled Component Pattern

A controlled component is one where React state controls the value of form elements. Every time the user types, you update state, and React re-renders the input with the new value.

# Basic Controlled Input

TypeScript Version

typescript
import { useState } from 'react';

export function ControlledInput() {
  const [email, setEmail] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.currentTarget.value);
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log('Submitted email:', email);
    setEmail(''); // Clear after submit
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email:</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={handleChange}
        placeholder="Enter your email"
      />
      <button type="submit">Submit</button>
    </form>
  );
}

JavaScript Version

javascript
import { useState } from 'react';

export function ControlledInput() {
  const [email, setEmail] = useState('');

  const handleChange = (e) => {
    setEmail(e.currentTarget.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted email:', email);
    setEmail('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email:</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={handleChange}
        placeholder="Enter your email"
      />
      <button type="submit">Submit</button>
    </form>
  );
}

# Controlled Inputs with Validation

The power of controlled components shines with real-time validation:

TypeScript Version

typescript
import { useState } from 'react';

interface FormState {
  email: string;
  password: string;
  errors: { email?: string; password?: string };
}

export function LoginForm() {
  const [form, setForm] = useState<FormState>({
    email: '',
    password: '',
    errors: {},
  });

  const validateEmail = (email: string): string | undefined => {
    if (!email.includes('@')) {
      return 'Invalid email format';
    }
    return undefined;
  };

  const validatePassword = (password: string): string | undefined => {
    if (password.length < 8) {
      return 'Password must be at least 8 characters';
    }
    return undefined;
  };

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>,
    field: 'email' | 'password'
  ) => {
    const value = e.currentTarget.value;
    const validator = field === 'email' ? validateEmail : validatePassword;
    const error = validator(value);

    setForm({
      ...form,
      [field]: value,
      errors: { ...form.errors, [field]: error },
    });
  };

  const isFormValid = !form.errors.email && !form.errors.password;

  return (
    <form className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={form.email}
          onChange={(e) => handleChange(e, 'email')}
          className={`w-full px-3 py-2 border rounded ${
            form.errors.email ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {form.errors.email && (
          <p className="text-red-600 text-sm mt-1">{form.errors.email}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={form.password}
          onChange={(e) => handleChange(e, 'password')}
          className={`w-full px-3 py-2 border rounded ${
            form.errors.password ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {form.errors.password && (
          <p className="text-red-600 text-sm mt-1">{form.errors.password}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={!isFormValid}
        className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
      >
        Login
      </button>
    </form>
  );
}

JavaScript Version

javascript
import { useState } from 'react';

export function LoginForm() {
  const [form, setForm] = useState({
    email: '',
    password: '',
    errors: {},
  });

  const validateEmail = (email) => {
    if (!email.includes('@')) {
      return 'Invalid email format';
    }
    return undefined;
  };

  const validatePassword = (password) => {
    if (password.length < 8) {
      return 'Password must be at least 8 characters';
    }
    return undefined;
  };

  const handleChange = (e, field) => {
    const value = e.currentTarget.value;
    const validator = field === 'email' ? validateEmail : validatePassword;
    const error = validator(value);

    setForm({
      ...form,
      [field]: value,
      errors: { ...form.errors, [field]: error },
    });
  };

  const isFormValid = !form.errors.email && !form.errors.password;

  return (
    <form className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={form.email}
          onChange={(e) => handleChange(e, 'email')}
          className={`w-full px-3 py-2 border rounded ${
            form.errors.email ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {form.errors.email && (
          <p className="text-red-600 text-sm mt-1">{form.errors.email}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={form.password}
          onChange={(e) => handleChange(e, 'password')}
          className={`w-full px-3 py-2 border rounded ${
            form.errors.password ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {form.errors.password && (
          <p className="text-red-600 text-sm mt-1">{form.errors.password}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={!isFormValid}
        className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
      >
        Login
      </button>
    </form>
  );
}

# Advantages of Controlled Components

React manages the single source of truth, making it easy to validate in real-time, disable buttons conditionally, and clear fields on demand. You can access field values synchronously without reaching into the DOM.

# Uncontrolled Components Explained

# The Uncontrolled Component Pattern

With uncontrolled components, you let the DOM manage the value. React doesn't track changes in state. Instead, you extract values when needed using refs or the FormData API.

# Basic Uncontrolled Input with Refs

TypeScript Version

typescript
import { useRef } from 'react';

export function UncontrolledInput() {
  const emailRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (emailRef.current) {
      console.log('Submitted email:', emailRef.current.value);
      emailRef.current.value = ''; // Manually clear
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email:</label>
      <input
        id="email"
        type="email"
        ref={emailRef}
        placeholder="Enter your email"
        defaultValue=""
      />
      <button type="submit">Submit</button>
    </form>
  );
}

JavaScript Version

javascript
import { useRef } from 'react';

export function UncontrolledInput() {
  const emailRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (emailRef.current) {
      console.log('Submitted email:', emailRef.current.value);
      emailRef.current.value = '';
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email:</label>
      <input
        id="email"
        type="email"
        ref={emailRef}
        placeholder="Enter your email"
        defaultValue=""
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Key difference: Notice defaultValue instead of value. The input now owns its value, not React.

# Uncontrolled Forms with FormData API

Modern browsers provide the FormData API, making uncontrolled forms elegant:

TypeScript Version

typescript
import { useState } from 'react';

interface ContactData {
  name: string;
  email: string;
  message: string;
  subscribe: boolean;
}

export function ContactForm() {
  const [submitted, setSubmitted] = useState(false);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    
    const formData = new FormData(e.currentTarget);
    const data: ContactData = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      message: formData.get('message') as string,
      subscribe: formData.get('subscribe') === 'on',
    };

    console.log('Form data:', data);
    setSubmitted(true);

    // Reset form
    e.currentTarget.reset();
  };

  return (
    <div>
      {submitted && (
        <p className="text-green-600 mb-4">Thank you! Message sent.</p>
      )}
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium">
            Name
          </label>
          <input
            id="name"
            name="name"
            type="text"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium">
            Email
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <div>
          <label htmlFor="message" className="block text-sm font-medium">
            Message
          </label>
          <textarea
            id="message"
            name="message"
            rows={5}
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <label className="flex items-center">
          <input
            name="subscribe"
            type="checkbox"
            className="w-4 h-4"
          />
          <span className="ml-2">Subscribe to updates</span>
        </label>

        <button
          type="submit"
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
          Send Message
        </button>
      </form>
    </div>
  );
}

JavaScript Version

javascript
import { useState } from 'react';

export function ContactForm() {
  const [submitted, setSubmitted] = useState(false);

  const handleSubmit = (e) => {
    e.preventDefault();
    
    const formData = new FormData(e.currentTarget);
    const data = {
      name: formData.get('name'),
      email: formData.get('email'),
      message: formData.get('message'),
      subscribe: formData.get('subscribe') === 'on',
    };

    console.log('Form data:', data);
    setSubmitted(true);
    e.currentTarget.reset();
  };

  return (
    <div>
      {submitted && (
        <p className="text-green-600 mb-4">Thank you! Message sent.</p>
      )}
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium">
            Name
          </label>
          <input
            id="name"
            name="name"
            type="text"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium">
            Email
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <div>
          <label htmlFor="message" className="block text-sm font-medium">
            Message
          </label>
          <textarea
            id="message"
            name="message"
            rows={5}
            required
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <label className="flex items-center">
          <input
            name="subscribe"
            type="checkbox"
            className="w-4 h-4"
          />
          <span className="ml-2">Subscribe to updates</span>
        </label>

        <button
          type="submit"
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
          Send Message
        </button>
      </form>
    </div>
  );
}

Advantages of uncontrolled forms: Less state management, no re-renders on every keystroke, simpler code for simple forms.

# Performance Comparison

# Rendering Behavior: Controlled vs Uncontrolled

Let's visualize what happens during user input:

Controlled Component:

  1. User types in input
  2. onChange fires
  3. State updates (setEmail)
  4. Component re-renders
  5. Input gets new value from state
  6. Total: Full component re-render on every keystroke

Uncontrolled Component:

  1. User types in input
  2. DOM updates the input value directly
  3. No state change, no re-render
  4. Total: Zero re-renders during input

# Real Performance Impact

For a simple form with one or two inputs, the difference is negligible. But in complex forms, controlled components cause measurable performance issues:

typescript
// PROBLEMATIC: Every keystroke triggers re-render of entire form
function ComplexForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [address, setAddress] = useState('');
  const [zipCode, setZipCode] = useState('');
  const [terms, setTerms] = useState(false);
  // ...15 more fields

  return (
    <form>
      {/* Complex calculations happen on every keystroke */}
      {expensiveValidation(email, password)}
      {/* Multiple dependent fields re-render */}
      {/* All your form UI re-renders for every keystroke */}
    </form>
  );
}

Uncontrolled approach is more efficient here.

# When to Measure Performance

Use React DevTools Profiler to check if your form is problematic:

typescript
// Enable in React DevTools → Profiler tab
// Fill out form and check:
// - Are components re-rendering on every keystroke?
// - How long does each render take?
// - Is the render work justified?

function MeasuredForm() {
  const [formData, setFormData] = useState({
    // many fields
  });

  // If Profiler shows excessive renders, consider uncontrolled
  return <form>{/* ... */}</form>;
}

# When to Use Each Pattern

# Use Controlled Components When:

You need real-time validation or feedback as the user types. You want to disable/enable buttons based on form state. You need to dynamically show/hide fields based on other field values. You're building a multi-step form where state persists between steps. You need to implement features like "unsaved changes" warnings. You're integrating with a form state library (react-hook-form, Formik).

typescript
// Controlled is better here - need real-time validation
function PasswordChecker() {
  const [password, setPassword] = useState('');
  const strength = calculateStrength(password);

  return (
    <>
      <input
        value={password}
        onChange={(e) => setPassword(e.currentTarget.value)}
      />
      <div className={`strength-${strength}`}>
        Strength: {strength}
      </div>
    </>
  );
}

# Use Uncontrolled Components When:

The form is simple with minimal validation logic. You only need values on form submission. You're building a basic search, filter, or contact form. You're dealing with file inputs (which can't be controlled). Performance is critical and you have many form fields.

typescript
// Uncontrolled is better here - simple form, submit only
function SearchForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const query = formData.get('search') as string;
    performSearch(query);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="search" type="text" placeholder="Search..." />
      <button type="submit">Search</button>
    </form>
  );
}

# Advanced: Hybrid Approaches

# Mostly Uncontrolled with Validation on Submit

Get simplicity while keeping validation:

TypeScript Version

typescript
import { useRef } from 'react';

interface FormErrors {
  email?: string;
  password?: string;
}

export function HybridForm() {
  const formRef = useRef<HTMLFormElement>(null);
  const [errors, setErrors] = useRef<FormErrors>({}).current;

  const validate = (formData: FormData): FormErrors => {
    const errors: FormErrors = {};
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    if (!email.includes('@')) {
      errors.email = 'Invalid email';
    }
    if (password.length < 8) {
      errors.password = 'Password too short';
    }

    return errors;
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const validationErrors = validate(formData);

    if (Object.keys(validationErrors).length === 0) {
      // Submit valid form
      console.log('Submitting:', Object.fromEntries(formData));
    } else {
      setErrors(validationErrors);
    }
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="email" type="email" defaultValue="" />
      {errors.email && <p>{errors.email}</p>}
      
      <input name="password" type="password" defaultValue="" />
      {errors.password && <p>{errors.password}</p>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

JavaScript Version

javascript
import { useRef } from 'react';

export function HybridForm() {
  const formRef = useRef(null);
  const errors = useRef({}).current;

  const validate = (formData) => {
    const errors = {};
    const email = formData.get('email');
    const password = formData.get('password');

    if (!email.includes('@')) {
      errors.email = 'Invalid email';
    }
    if (password.length < 8) {
      errors.password = 'Password too short';
    }

    return errors;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const validationErrors = validate(formData);

    if (Object.keys(validationErrors).length === 0) {
      console.log('Submitting:', Object.fromEntries(formData));
    } else {
      errors = validationErrors;
    }
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="email" type="email" defaultValue="" />
      {errors.email && <p>{errors.email}</p>}
      
      <input name="password" type="password" defaultValue="" />
      {errors.password && <p>{errors.password}</p>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

# Using Form State Libraries

For complex forms, libraries like react-hook-form provide the best of both worlds:

typescript
import { useForm } from 'react-hook-form';

interface SignupData {
  email: string;
  password: string;
  confirmPassword: string;
}

export function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupData>();

  const onSubmit = (data: SignupData) => {
    console.log('Valid data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /@/, message: 'Invalid email' }
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

      <button type="submit">Sign up</button>
    </form>
  );
}

Libraries like react-hook-form use an uncontrolled approach internally but manage validation and state for you.

# Common Patterns and Pitfalls

# Pitfall 1: Mixing Controlled and Uncontrolled

typescript
// ❌ WRONG: Changing from uncontrolled to controlled mid-render
function BadForm() {
  const [isControlled, setIsControlled] = useState(false);

  // This causes React warnings
  return (
    <input
      value={isControlled ? 'some value' : undefined}
      onChange={(e) => console.log(e.currentTarget.value)}
    />
  );
}

// ✅ CORRECT: Pick one approach
function GoodForm() {
  const [email, setEmail] = useState('');
  return (
    <input
      value={email}
      onChange={(e) => setEmail(e.currentTarget.value)}
    />
  );
}

# Pitfall 2: Forgetting preventDefault on Form Submit

typescript
// ❌ WRONG: Page refreshes
const handleSubmit = (e) => {
  console.log('Submitting...');
  // Oops, forgot e.preventDefault()
};

// ✅ CORRECT: Prevent default form behavior
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  console.log('Submitting...');
};

# Pitfall 3: Unnecessary State in Simple Forms

typescript
// ❌ WRONG: State overhead for simple form
function SearchForm() {
  const [query, setQuery] = useState('');
  return (
    <form>
      <input
        value={query}
        onChange={(e) => setQuery(e.currentTarget.value)}
      />
      {/* Renders on every keystroke for no reason */}
    </form>
  );
}

// ✅ CORRECT: Uncontrolled for simple forms
function SearchForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const query = formData.get('search');
    // Only runs on submit
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="search" />
      <button>Search</button>
    </form>
  );
}

# FAQ

Q: Should I always use controlled components?

A: No. Controlled components add overhead with re-renders on every keystroke. Use them when you genuinely need real-time validation, conditional field display, or other state-driven features. For simple forms, uncontrolled is simpler and faster.

Q: Can I convert between controlled and uncontrolled?

A: Only at the component level. If you start rendering an input with value (controlled), you can't suddenly stop providing it—React will warn you. Create separate components if you need both patterns.

Q: What about file inputs?

A: File inputs are always uncontrolled. You can't set their value from JavaScript for security reasons. Use refs to access the FileList:

typescript
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
  const files = fileRef.current?.files;
};

Q: How do I validate a controlled component in real-time?

A: Update state on change and display errors immediately:

typescript
const [email, setEmail] = useState('');
const error = !email.includes('@') ? 'Invalid email' : null;

return (
  <>
    <input value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
    {error && <p className="text-red-600">{error}</p>}
  </>
);

Q: Is FormData browser support an issue?

A: FormData is supported in all modern browsers (including IE11 with polyfill). For older browser support, use refs to read individual inputs.

Q: How do I set a default value in uncontrolled components?

A: Use defaultValue or defaultChecked for inputs and checkboxes:

typescript
<input name="email" type="email" defaultValue="default@example.com" />
<textarea name="message" defaultValue="Default message" />
<input type="checkbox" defaultChecked={true} />

Key Takeaway: Choose controlled components when React needs to know about every state change, and uncontrolled components when the form is simple enough that you only need values on submit. Performance-conscious developers often reach for uncontrolled patterns first and only add controlled behavior when genuinely needed.

Questions? Which pattern do you prefer? Share your form architecture in the comments below.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT