Controlled 📋 useState VS Uncontrolled 📝 useRef ⚛️ React Forms
Frontend

React Forms: Controlled vs Uncontrolled Components

Mayur Dabhi
Mayur Dabhi
March 20, 2026
24 min read

Form handling is one of the most fundamental aspects of building interactive React applications. Whether you're creating a simple contact form, a complex multi-step wizard, or a real-time search interface, understanding how to manage form inputs effectively is crucial. React provides two distinct approaches to handling forms: Controlled Components and Uncontrolled Components.

In this comprehensive guide, we'll explore both approaches in depth, understand when to use each one, and master the art of building robust, maintainable forms in React. By the end of this article, you'll have the knowledge to make informed decisions about form handling in your React applications.

What You'll Learn
  • Understanding controlled vs uncontrolled components
  • When to use each approach (with decision flowchart)
  • Implementing controlled forms with useState
  • Implementing uncontrolled forms with useRef
  • Form validation techniques for both approaches
  • Handling complex form scenarios
  • Performance considerations and best practices
  • Real-world examples with complete code

Understanding the Fundamentals

Before diving into implementation details, let's establish a clear understanding of what makes a component "controlled" or "uncontrolled" in React's form handling paradigm.

Controlled Components

React state is the "single source of truth" for form data.

  • Form data stored in useState
  • Value attribute bound to state
  • onChange handler updates state
  • React controls the input
VS

Uncontrolled Components

The DOM is the "single source of truth" for form data.

  • Form data stored in the DOM
  • Access via useRef
  • Read values when needed
  • DOM controls the input
Data Flow: Controlled vs Uncontrolled Controlled Component Flow 👤 User Types 📤 onChange setState Re-render with new value Uncontrolled Component Flow 👤 User Types 🌐 DOM 🔗 ref.current Access value when needed (onSubmit) Key Difference: Source of Truth Controlled React State = Source of Truth Component always knows current value Uncontrolled DOM = Source of Truth Query DOM to get current value

Controlled components maintain state in React; uncontrolled components store data in the DOM

Controlled Components Deep Dive

In controlled components, form data is handled entirely by React. The component's state becomes the single source of truth, and every state mutation has an associated handler function. This approach gives you complete control over the form data at any point in time.

Basic Controlled Input

Let's start with a simple example of a controlled text input:

ControlledInput.jsx
import { useState } from 'react';

function ControlledInput() {
  // State holds the input value
  const [name, setName] = useState('');

  const handleChange = (event) => {
    // Update state on every keystroke
    setName(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    // State already has the current value
    console.log('Submitted name:', name);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input
          type="text"
          value={name}              {/* Bound to state */}
          onChange={handleChange}  {/* Updates state */}
        />
      </label>
      <button type="submit">Submit</button>
      <p>Current value: {name}</p>
    </form>
  );
}
Key Insight

Notice how the value attribute is always equal to name from state. This creates a "controlled" relationship where React state and the displayed value are always in sync. Every change triggers a re-render with the new value.

Handling Multiple Inputs

When dealing with multiple form fields, you can use a single state object and computed property names:

MultipleInputs.jsx
import { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    agreeToTerms: false
  });

  // Single handler for all inputs
  const handleChange = (event) => {
    const { name, value, type, checked } = event.target;
    
    setFormData(prev => ({
      ...prev,
      // Use checked for checkboxes, value for others
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Form data:', formData);
    // API call, validation, etc.
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="firstName"
        value={formData.firstName}
        onChange={handleChange}
        placeholder="First Name"
      />
      
      <input
        type="text"
        name="lastName"
        value={formData.lastName}
        onChange={handleChange}
        placeholder="Last Name"
      />
      
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Email"
      />
      
      <input
        type="password"
        name="password"
        value={formData.password}
        onChange={handleChange}
        placeholder="Password"
      />
      
      <label>
        <input
          type="checkbox"
          name="agreeToTerms"
          checked={formData.agreeToTerms}
          onChange={handleChange}
        />
        I agree to the terms
      </label>
      
      <button type="submit">Register</button>
    </form>
  );
}

Real-Time Validation with Controlled Components

One of the biggest advantages of controlled components is the ability to validate and transform input in real-time:

ValidatedForm.jsx
import { useState } from 'react';

function ValidatedForm() {
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  // Validation logic
  const validateEmail = (value) => {
    if (!value) {
      return 'Email is required';
    }
    if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
      return 'Invalid email address';
    }
    return null;
  };

  const handleChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    
    // Real-time validation
    if (touched.email) {
      const error = validateEmail(value);
      setErrors(prev => ({ ...prev, email: error }));
    }
  };

  const handleBlur = () => {
    setTouched(prev => ({ ...prev, email: true }));
    const error = validateEmail(email);
    setErrors(prev => ({ ...prev, email: error }));
  };

  const isValid = !errors.email && email.length > 0;

  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={handleChange}
        onBlur={handleBlur}
        style={{ borderColor: errors.email ? 'red' : 'green' }}
      />
      {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      <button disabled={!isValid}>Submit</button>
    </div>
  );
}

Uncontrolled Components Deep Dive

Uncontrolled components work more like traditional HTML form elements. Instead of writing an event handler for every state update, you use a ref to get form values from the DOM when you need them.

Basic Uncontrolled Input

UncontrolledInput.jsx
import { useRef } from 'react';

function UncontrolledInput() {
  // Create a ref to access the DOM element
  const inputRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault();
    // Access the value directly from the DOM
    console.log('Submitted name:', inputRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input
          type="text"
          ref={inputRef}           {/* Attach ref to element */}
          defaultValue="John"      {/* Initial value (not controlled) */}
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}
Important Difference

With uncontrolled components, use defaultValue instead of value for initial values. Using value without an onChange handler makes the input read-only!

File Input: A Perfect Use Case for Uncontrolled

File inputs are always uncontrolled in React because their value can only be set by a user, not programmatically:

FileUpload.jsx
import { useRef, useState } from 'react';

function FileUpload() {
  const fileInputRef = useRef(null);
  const [selectedFile, setSelectedFile] = useState(null);

  const handleSubmit = (event) => {
    event.preventDefault();
    
    const file = fileInputRef.current.files[0];
    if (file) {
      console.log('Selected file:', file.name);
      console.log('Size:', file.size, 'bytes');
      console.log('Type:', file.type);
      
      // Upload logic here...
      const formData = new FormData();
      formData.append('file', file);
      // fetch('/api/upload', { method: 'POST', body: formData });
    }
  };

  const handleFileChange = () => {
    const file = fileInputRef.current.files[0];
    setSelectedFile(file ? file.name : null);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleFileChange}
        accept="image/*,.pdf"
      />
      {selectedFile && <p>Selected: {selectedFile}</p>}
      <button type="submit">Upload</button>
    </form>
  );
}

Comparison Table

Here's a comprehensive comparison of both approaches:

Feature Controlled Uncontrolled
Data Storage React state (useState) DOM (via ref)
Value Access Always available via state Query DOM when needed
Real-time Validation ✅ Easy to implement ❌ Requires additional handlers
Instant Input Formatting ✅ Transform on every keystroke ❌ Not straightforward
Conditional Disable Submit ✅ Check state directly ❌ Need to track separately
Dynamic Inputs ✅ Add/remove fields easily ⚠️ More complex
Performance (many fields) ⚠️ Re-renders on each change ✅ No re-renders
Integration with React ✅ "React way" ⚠️ More imperative
File Inputs ❌ Not possible ✅ Only option
Code Complexity More boilerplate Less boilerplate

When to Use Which?

Choosing between controlled and uncontrolled components depends on your specific requirements. Here's a decision flowchart to help you decide:

Do you need real-time validation or input formatting?
↓ Yes → Use Controlled | No ↓
Do you need to conditionally disable submit based on form state?
↓ Yes → Use Controlled | No ↓
Is it a file input?
↓ Yes → Use Uncontrolled | No ↓
Are there 20+ form fields with performance concerns?
↓ Yes → Consider Uncontrolled | No ↓
Default to Controlled

Use Controlled When:

  • Real-time validation needed
  • Input masking/formatting required
  • Conditional rendering based on input
  • Multi-step form wizards
  • Form data drives other UI

Use Uncontrolled When:

  • Simple forms, submit-only validation
  • Integrating with non-React code
  • File uploads
  • Performance-critical scenarios
  • Quick prototyping

Complete Real-World Example

Let's build a complete registration form using controlled components with validation, error handling, and a great user experience:

import { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });
  
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validate = (data) => {
    const errors = {};
    
    if (!data.username) {
      errors.username = 'Username is required';
    } else if (data.username.length < 3) {
      errors.username = 'Username must be at least 3 characters';
    }
    
    if (!data.email) {
      errors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(data.email)) {
      errors.email = 'Email is invalid';
    }
    
    if (!data.password) {
      errors.password = 'Password is required';
    } else if (data.password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }
    
    if (data.password !== data.confirmPassword) {
      errors.confirmPassword = 'Passwords do not match';
    }
    
    return errors;
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    // Validate on change if field was touched
    if (touched[name]) {
      const newErrors = validate({ ...formData, [name]: value });
      setErrors(prev => ({ ...prev, [name]: newErrors[name] }));
    }
  };

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    const newErrors = validate(formData);
    setErrors(prev => ({ ...prev, [name]: newErrors[name] }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    const validationErrors = validate(formData);
    setErrors(validationErrors);
    setTouched({
      username: true,
      email: true,
      password: true,
      confirmPassword: true
    });

    if (Object.keys(validationErrors).length === 0) {
      setIsSubmitting(true);
      try {
        // API call here
        await submitForm(formData);
        alert('Registration successful!');
      } catch (error) {
        setErrors({ submit: error.message });
      } finally {
        setIsSubmitting(false);
      }
    }
  };

  const isFormValid = Object.keys(validate(formData)).length === 0;

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields with error display */}
      <button 
        type="submit" 
        disabled={!isFormValid || isSubmitting}
      >
        {isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
}
// validators.js - Reusable validation functions

export const required = (value) => 
  value ? undefined : 'This field is required';

export const minLength = (min) => (value) =>
  value && value.length >= min 
    ? undefined 
    : `Must be at least ${min} characters`;

export const maxLength = (max) => (value) =>
  value && value.length <= max 
    ? undefined 
    : `Must be ${max} characters or less`;

export const email = (value) =>
  value && /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)
    ? undefined
    : 'Invalid email address';

export const passwordStrength = (value) => {
  if (!value) return undefined;
  
  const hasUpperCase = /[A-Z]/.test(value);
  const hasLowerCase = /[a-z]/.test(value);
  const hasNumbers = /\d/.test(value);
  const hasSpecial = /[!@#$%^&*]/.test(value);
  
  if (!(hasUpperCase && hasLowerCase && hasNumbers && hasSpecial)) {
    return 'Password must include uppercase, lowercase, number, and special character';
  }
  return undefined;
};

export const matches = (field, fieldName) => (value, allValues) =>
  value === allValues[field]
    ? undefined
    : `Must match ${fieldName}`;

// Compose multiple validators
export const composeValidators = (...validators) => (value, allValues) =>
  validators.reduce(
    (error, validator) => error || validator(value, allValues),
    undefined
  );
// useForm.js - Custom hook for form handling

import { useState, useCallback } from 'react';

export function useForm(initialValues, validate, onSubmit) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;
    
    setValues(prev => {
      const newValues = { ...prev, [name]: fieldValue };
      
      if (touched[name] && validate) {
        const newErrors = validate(newValues);
        setErrors(newErrors);
      }
      
      return newValues;
    });
  }, [touched, validate]);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    
    if (validate) {
      const newErrors = validate(values);
      setErrors(newErrors);
    }
  }, [values, validate]);

  const handleSubmit = useCallback(async (e) => {
    e.preventDefault();
    
    const validationErrors = validate ? validate(values) : {};
    setErrors(validationErrors);
    
    // Touch all fields
    const allTouched = Object.keys(values).reduce(
      (acc, key) => ({ ...acc, [key]: true }), {}
    );
    setTouched(allTouched);

    if (Object.keys(validationErrors).length === 0) {
      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [values, validate, onSubmit]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    setFieldValue: (name, value) => setValues(prev => ({ ...prev, [name]: value })),
    setFieldError: (name, error) => setErrors(prev => ({ ...prev, [name]: error }))
  };
}

Performance Considerations

Understanding the performance implications of each approach is crucial for building responsive forms:

Controlled Component Performance Tips

  • Debounce expensive operations: Don't validate or make API calls on every keystroke
  • Use useMemo for computed values: Avoid recalculating validation on every render
  • Split forms into components: Isolate re-renders to specific sections
  • Consider useReducer: For complex forms with many fields
// Debounce example
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);

useEffect(() => {
  if (debouncedSearch) {
    fetchResults(debouncedSearch);
  }
}, [debouncedSearch]);

When Uncontrolled Shines

Uncontrolled components avoid re-renders entirely, making them ideal for:

  • Forms with 50+ input fields
  • Data grids with editable cells
  • When integrating with third-party libraries
  • Simple forms that only validate on submit

Common Pitfalls & Solutions

Pitfall #1: Mixing Controlled and Uncontrolled

Never switch between controlled and uncontrolled for the same input. This causes React warnings and unexpected behavior.

// ❌ BAD - undefined to string
const [value, setValue] = useState(); // undefined!
<input value={value} />

// ✅ GOOD - always a string
const [value, setValue] = useState('');
<input value={value} />
Pitfall #2: Forgetting defaultValue for Uncontrolled

Using value without onChange makes the input read-only. Use defaultValue for uncontrolled inputs.

// ❌ BAD - read-only input
<input value="fixed" ref={inputRef} />

// ✅ GOOD - editable uncontrolled input
<input defaultValue="editable" ref={inputRef} />
Pitfall #3: Re-rendering Entire Forms

If your entire form re-renders on every keystroke, split it into smaller components.

// ✅ GOOD - Isolated component
function EmailField({ value, onChange, error }) {
  return (
    <div>
      <input value={value} onChange={onChange} />
      {error && <span>{error}</span>}
    </div>
  );
}

// Wrap with React.memo if needed
export default React.memo(EmailField);

Best Practices Summary

Default to controlled components - They're the "React way" and provide better control over form state.
Initialize state with empty strings - Never use undefined for controlled inputs to avoid switching modes.
Use a single handler for multiple inputs - Leverage computed property names with the input's name attribute.
Create a custom useForm hook - Extract form logic for reusability across your application.
Use uncontrolled for file inputs - They're always uncontrolled; don't fight it.
Consider form libraries for complex forms - React Hook Form, Formik, or Final Form handle edge cases well.

Conclusion

Choosing between controlled and uncontrolled components isn't about which is "better" - it's about selecting the right tool for your specific needs:

Key Takeaways

  • Controlled components give you full control over form data, enabling real-time validation, input formatting, and conditional logic. They're the recommended approach for most React forms.
  • Uncontrolled components are simpler to implement for basic forms and perform better with many fields since they avoid re-renders. They're essential for file inputs.
  • Don't mix approaches for the same input - pick one and stick with it.
  • Consider using form libraries like React Hook Form for complex scenarios - they optimize performance while providing a controlled-like API.

Now that you understand both approaches deeply, you can make informed decisions about form handling in your React applications. Start with controlled components for most cases, and reach for uncontrolled when you have a specific reason to do so.

Happy coding! 🚀

React Forms Input Controlled Components Uncontrolled Components useState useRef Validation
Mayur Dabhi

Mayur Dabhi

Full Stack Developer specializing in React, Laravel, and modern web technologies. Passionate about creating efficient, scalable solutions and sharing knowledge with the developer community.