Controlled πŸ“‹ useState VS Uncontrolled πŸ“ useRef βš›οΈ React Forms
Frontend

React Forms: Controlled vs Uncontrolled Components

Mayur Dabhi
Mayur Dabhi
March 20, 2026
24 min read

You add a value prop to an input, open the page, and suddenly you can't type into it. Or the console starts yelling that "a component is changing an uncontrolled input to be controlled." Both problems come from the same place: React has two different ways of owning a form field, and it's easy to end up halfway between them. Those two ways are Controlled Components and Uncontrolled Components.

This post walks through both with working code, shows where each one fits, covers validation and performance, and lists the mistakes that cause those warnings in the first place.

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

The whole difference comes down to one question: who holds the current value of the field, React or the browser?

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 in Practice

With a controlled input, the field shows whatever is in state and nothing else. Every keystroke goes through your onChange handler, you update state, and React re-renders the input with the new value. That's more wiring, but it means you can read, change, or reject the value at any moment.

Basic Controlled 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>
  );
}
What's Going On

The value attribute is always name from state, so what you see on screen and what's in state can't drift apart. The cost is a re-render of this component on every keystroke.

Handling Multiple Inputs

You don't need one useState and one handler per field. Give each input a name, keep everything in one object, and use a computed property key:

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

Because every change passes through your code, you can validate or reformat input as the user types. This example waits until the field has been blurred once before showing errors, so people don't get yelled at on the first character:

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 in Practice

Here you let the browser keep the value, the way plain HTML forms always have. No state, no handler per keystroke. You attach a ref and read the value when you actually need it, usually on submit.

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

For an uncontrolled input, set the starting value with defaultValue, not value. If you pass value without onChange, the input becomes read-only and React logs "You provided a `value` prop to a form field without an `onChange` handler." Also note that changing defaultValue later does nothing; it only applies on mount. If you need to reset the field, change its key so React remounts it.

File Inputs: Uncontrolled Whether You Like It or Not

An <input type="file"> is always uncontrolled in React. Browsers don't let scripts set its value (for obvious security reasons), so you read files off a ref instead:

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

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?

Most of the time the answer falls out of what the form needs to do while the user is still typing. If nothing needs to react until submit, you probably don't need state. Here's the order I'd ask the questions in:

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

A Fuller Example: Registration Form

Here's a controlled registration form with touched tracking, validation, and a submitting state, followed by the validators split into their own file and the same logic pulled into a useForm hook:

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

For a login form, none of this matters. It starts to matter when a big form lives in one component and every keystroke re-renders all of it, or when typing triggers validation or network calls.

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

Typing into an uncontrolled input doesn't re-render anything, which helps with:

  • Very large forms (dozens of 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

If a field's value starts as undefined and later becomes a string, React treats it as uncontrolled first and controlled after, and warns: "A component is changing an uncontrolled input to be controlled." This often happens when state is filled from an API response and some fields come back as null. Default them with value={user.phone ?? ''}.

// ❌ 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 one big component holds all the state, every keystroke re-renders the whole form. Pull fields into their own components and memoize them, but remember React.memo only helps if the onChange you pass down is stable (wrapped in useCallback).

// βœ… 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);

Habits That Keep Forms Sane

Start controlled. It's what most React code expects, and it's easier to bolt on validation later.
Initialize every field. Use '' (or false for checkboxes), never undefined, so inputs don't flip modes.
One handler, many inputs. Use the input's name attribute with a computed property key.
Pull repeated logic into a hook. Once you've written touched/errors/submitting twice, a useForm hook pays for itself.
Don't fight file inputs. They're uncontrolled; read them from a ref.
Know when to stop hand-rolling. React Hook Form, Formik, or Final Form already handle the edge cases.

When I'd Pick Each (and When I Wouldn't)

I reach for controlled inputs when something on screen depends on the value while the user is typing: a live character count, a formatted phone number, a submit button that enables only when the form is valid, or fields that appear based on earlier answers. I wouldn't bother for a search box that only fires on Enter or a simple contact form validated on submit. There, a ref or plain FormData from the submit event is less code and nothing re-renders.

Uncontrolled is the right call for file inputs, for wrapping non-React widgets that manage their own DOM, and for very large forms where re-rendering on every key becomes noticeable. I'd avoid it when other parts of the UI need the value, or when you have to reset or prefill fields after mount, since defaultValue won't help you there.

Once a form has many fields, cross-field rules, and async validation, I stop writing either version by hand. React Hook Form uses uncontrolled inputs under the hood with refs and still gives you a validation API that feels controlled, which is usually the better trade than managing a dozen useState calls yourself.

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.