Controlled vs Uncontrolled Components

Understanding the two fundamental approaches to form handling in React

Introduction

When working with forms in React, you'll encounter two fundamental approaches to handle form inputs: controlled components and uncontrolled components. Understanding the differences, trade-offs, and appropriate use cases for each approach is crucial for building effective React applications.

Real-world analogy: Think of controlled vs. uncontrolled components like two different ways to manage a notepad. With a controlled component, it's like having someone write down exactly what you dictate (React state manages everything). With an uncontrolled component, it's like giving someone a notepad and checking later what they've written (the DOM manages the state until you need it).

graph TD A[Form Components in React] A --> B[Controlled Components] A --> C[Uncontrolled Components] B --> D[React State Manages Values] B --> E[Event Handlers Update State] B --> F[Component Re-renders on Change] C --> G[DOM Manages Values] C --> H[Access via Refs] C --> I[Minimal Re-renders] style A fill:#f9f,stroke:#333,stroke-width:2px

Controlled Components

Definition

A controlled component is a form element whose value is controlled by React state. The component receives its current value as a prop and includes a callback to change that value. This makes React the "single source of truth" for the component's state.

How Controlled Components Work

  1. Form data is stored in the component's state
  2. Input value is set from the state
  3. When user types, onChange handler updates the state
  4. Component re-renders with the new value
  5. Current form data is always available in your component's state
sequenceDiagram participant User participant Component participant State participant DOM Note over Component,State: Initial render Component->>State: Initialize state Component->>DOM: Render with initial value Note over User,DOM: User interaction User->>DOM: Type "Hello" DOM->>Component: Trigger onChange Component->>State: Update state to "Hello" State->>Component: Provide updated state Component->>DOM: Re-render with "Hello"

Basic Example


import React, { useState } from 'react';

function ControlledInput() {
  const [inputValue, setInputValue] = useState('');
  
  const handleChange = (event) => {
    setInputValue(event.target.value);
  };
  
  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Type something..."
      />
      <p>You typed: {inputValue}</p>
    </div>
  );
}
      

Advantages of Controlled Components

Disadvantages of Controlled Components

Uncontrolled Components

Definition

An uncontrolled component is a form element that maintains its own internal state. Instead of updating state on every change, you use a ref to get the field's value when needed (typically during form submission).

How Uncontrolled Components Work

  1. Form data is handled by the DOM itself
  2. You provide a default value (if needed) via the defaultValue prop
  3. You create a ref to access the DOM element
  4. You read the value from the ref when you need it (e.g., on form submission)
  5. React doesn't re-render on every keystroke
sequenceDiagram participant User participant Component participant DOM participant Ref Note over Component,DOM: Initial render Component->>DOM: Render with defaultValue Component->>Ref: Create and attach ref Note over User,DOM: User interaction User->>DOM: Type "Hello" DOM->>DOM: Update internal value Note over User,Component: Form submission User->>Component: Submit form Component->>Ref: Access ref Ref->>DOM: Get current value DOM->>Ref: Return "Hello" Ref->>Component: Provide value for processing

Basic Example


import React, { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);
  
  const handleSubmit = (event) => {
    event.preventDefault();
    alert(`You entered: ${inputRef.current.value}`);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        ref={inputRef}
        defaultValue=""
        placeholder="Type something..."
      />
      <button type="submit">Submit</button>
    </form>
  );
}
      

Advantages of Uncontrolled Components

Disadvantages of Uncontrolled Components

Key Differences: At a Glance

Aspect Controlled Components Uncontrolled Components
Source of Truth React State DOM
Data Access Immediate (via state) On-demand (via refs)
Value Setting value prop defaultValue prop
Change Handling onChange handler Not required
Re-renders On every change Only when explicitly triggered
Form Validation Real-time On submission (typically)
Code Volume More Less

Code Comparison

Controlled Component


function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  const handleNameChange = (e) => {
    setName(e.target.value);
  };
  
  const handleEmailChange = (e) => {
    setEmail(e.target.value);
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({ name, email });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={name} 
        onChange={handleNameChange} 
      />
      <input 
        value={email} 
        onChange={handleEmailChange} 
      />
      <button type="submit">Submit</button>
    </form>
  );
}
          

Uncontrolled Component


function UncontrolledForm() {
  const nameRef = useRef();
  const emailRef = useRef();
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({
      name: nameRef.current.value,
      email: emailRef.current.value
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="" />
      <input ref={emailRef} defaultValue="" />
      <button type="submit">Submit</button>
    </form>
  );
}
          

When to Use Each Approach

Use Controlled Components When:

Use Uncontrolled Components When:

flowchart TD A{Need real-time
access to value?} -->|Yes| B[Controlled] A -->|No| C[Uncontrolled] B --> D{Need instant
validation?} D -->|Yes| E[Controlled] D -->|No| F{Many input
fields?} F -->|Yes| G{Performance
concerns?} F -->|No| E G -->|Yes| H[Consider
Uncontrolled] G -->|No| E C --> I{File uploads?} I -->|Yes| J[Uncontrolled] I -->|No| K{Third-party
library?} K -->|Yes| J K -->|No| L{Simple
form?} L -->|Yes| J L -->|No| M[Consider
Controlled]

Special Cases & Hybrid Approaches

File Inputs

File inputs are inherently uncontrolled in React because their value is read-only for security reasons:


function FileUploader() {
  const fileInputRef = useRef(null);
  const [fileName, setFileName] = useState('');
  
  const handleFileChange = (event) => {
    const file = event.target.files[0];
    if (file) {
      setFileName(file.name);
    }
  };
  
  return (
    <div>
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleFileChange}
      />
      {fileName && <p>Selected file: {fileName}</p>}
      
      <button
        onClick={() => fileInputRef.current.click()}
        type="button"
      >
        Select File
      </button>
    </div>
  );
}
      

In this example, the file input itself is uncontrolled (we use a ref to access it), but we maintain a piece of controlled state (fileName) to display information about the selected file.

Hybrid Approach with Lazy Initialization

Sometimes you may want to initialize a controlled component from an external source. Here's a pattern for creating a controlled component from an uncontrolled value:


function ProfileEditor({ userId }) {
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // Fetch initial data
  useEffect(() => {
    async function fetchProfile() {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setProfile(data);
      } catch (error) {
        console.error('Error fetching profile:', error);
      } finally {
        setLoading(false);
      }
    }
    
    fetchProfile();
  }, [userId]);
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setProfile(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(profile)
      });
      alert('Profile updated!');
    } catch (error) {
      console.error('Error updating profile:', error);
    }
  };
  
  if (loading) return <div>Loading...</div>;
  if (!profile) return <div>Error loading profile</div>;
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="displayName">Display Name:</label>
        <input
          id="displayName"
          name="displayName"
          value={profile.displayName}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="bio">Bio:</label>
        <textarea
          id="bio"
          name="bio"
          value={profile.bio}
          onChange={handleChange}
        />
      </div>
      
      <button type="submit">Save Profile</button>
    </form>
  );
}
      

This pattern is useful when:

Using Refs with Controlled Components

Sometimes you need DOM manipulation with controlled components, such as focusing an input:


function SearchForm() {
  const [query, setQuery] = useState('');
  const inputRef = useRef(null);
  
  const handleChange = (e) => {
    setQuery(e.target.value);
  };
  
  const handleClear = () => {
    setQuery('');
    // Focus the input after clearing
    inputRef.current.focus();
  };
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        ref={inputRef}
        placeholder="Search..."
      />
      
      {query && (
        <button onClick={handleClear} type="button">
          Clear
        </button>
      )}
    </div>
  );
}
      

This combines the best of both worlds: controlled state management with direct DOM access when needed.

Optimizing Performance

Performance Considerations for Controlled Components

Since controlled components re-render on every change, they can potentially impact performance, especially in forms with many fields. Here are some optimization strategies:

Debouncing Input Updates


import { useState, useEffect, useCallback } from 'react';
import debounce from 'lodash/debounce';

function DebouncedInput() {
  // State for immediate UI updates
  const [inputValue, setInputValue] = useState('');
  // State for debounced operations (e.g., API calls)
  const [debouncedValue, setDebouncedValue] = useState('');
  
  // Create a debounced function (created only once)
  const debouncedSetValue = useCallback(
    debounce(value => {
      setDebouncedValue(value);
      console.log('Debounced update:', value);
      // Perform expensive operations here
    }, 500),
    [] // Empty dependency array means this is created only once
  );
  
  // Handle input changes
  const handleChange = (e) => {
    const value = e.target.value;
    setInputValue(value); // Update UI immediately
    debouncedSetValue(value); // Schedule debounced update
  };
  
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      debouncedSetValue.cancel();
    };
  }, [debouncedSetValue]);
  
  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Type to search..."
      />
      <p>Immediate value: {inputValue}</p>
      <p>Debounced value: {debouncedValue}</p>
    </div>
  );
}
      

Optimizing Multiple Fields with useMemo


function OptimizedForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    // More fields...
  });
  
  // Memoize the validation result
  const validationResult = useMemo(() => {
    // Expensive validation logic
    const errors = {};
    
    if (!formData.firstName) {
      errors.firstName = 'First name is required';
    }
    
    if (!formData.email) {
      errors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      errors.email = 'Email is invalid';
    }
    
    // More validations...
    
    return {
      isValid: Object.keys(errors).length === 0,
      errors
    };
  }, [formData]); // Only recalculate when formData changes
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  return (
    <form>
      <div>
        <input
          name="firstName"
          value={formData.firstName}
          onChange={handleChange}
        />
        {validationResult.errors.firstName && (
          <p className="error">{validationResult.errors.firstName}</p>
        )}
      </div>
      
      <div>
        <input
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
        {validationResult.errors.email && (
          <p className="error">{validationResult.errors.email}</p>
        )}
      </div>
      
      <button 
        type="submit" 
        disabled={!validationResult.isValid}
      >
        Submit
      </button>
    </form>
  );
}
      

Real-World Examples

Login Form (Controlled)


function LoginForm() {
  const [credentials, setCredentials] = useState({
    username: '',
    password: ''
  });
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setCredentials(prev => ({
      ...prev,
      [name]: value
    }));
    
    // Clear errors when user types
    if (errors[name]) {
      setErrors(prev => {
        const newErrors = { ...prev };
        delete newErrors[name];
        return newErrors;
      });
    }
  };
  
  const validate = () => {
    const newErrors = {};
    
    if (!credentials.username.trim()) {
      newErrors.username = 'Username is required';
    }
    
    if (!credentials.password) {
      newErrors.password = 'Password is required';
    } else if (credentials.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    if (!validate()) return;
    
    setIsSubmitting(true);
    
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // Here you would actually call your authentication API
      console.log('Logging in with:', credentials);
      
      // On success, you might redirect or update state
      alert('Login successful!');
    } catch (error) {
      setErrors({ form: 'Login failed. Please try again.' });
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit} className="login-form">
      

Login

{errors.form && ( <div className="error-message">{errors.form}</div> )} <div className="form-group"> <label htmlFor="username">Username</label> <input id="username" name="username" type="text" value={credentials.username} onChange={handleChange} className={errors.username ? 'error' : ''} disabled={isSubmitting} /> {errors.username && ( <div className="error-message">{errors.username}</div> )} </div> <div className="form-group"> <label htmlFor="password">Password</label> <input id="password" name="password" type="password" value={credentials.password} onChange={handleChange} className={errors.password ? 'error' : ''} disabled={isSubmitting} /> {errors.password && ( <div className="error-message">{errors.password}</div> )} </div> <button type="submit" disabled={isSubmitting} className="login-button" > {isSubmitting ? 'Logging in...' : 'Log In'} </button> </form> ); }

Comment Form (Uncontrolled)


function CommentForm({ postId, onCommentAdded }) {
  const nameRef = useRef(null);
  const commentRef = useRef(null);
  const formRef = useRef(null);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    const name = nameRef.current.value.trim();
    const comment = commentRef.current.value.trim();
    
    // Basic validation
    if (!name || !comment) {
      alert('Please fill in all fields');
      return;
    }
    
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 500));
      
      // Here you would actually submit to your API
      console.log('Adding comment:', { postId, name, comment });
      
      // Notify parent component
      onCommentAdded({ name, comment, date: new Date() });
      
      // Reset form
      formRef.current.reset();
    } catch (error) {
      console.error('Error adding comment:', error);
      alert('Failed to add comment. Please try again.');
    }
  };
  
  return (
    <form ref={formRef} onSubmit={handleSubmit} className="comment-form">
      <h3>Add a Comment</h3>
      
      <div className="form-group">
        <label htmlFor="name">Name</label>
        <input
          id="name"
          ref={nameRef}
          type="text"
          defaultValue=""
          required
        />
      </div>
      
      <div className="form-group">
        <label htmlFor="comment">Comment</label>
        <textarea
          id="comment"
          ref={commentRef}
          rows="4"
          defaultValue=""
          required
        />
      </div>
      
      <button type="submit" className="submit-button">
        Post Comment
      </button>
    </form>
  );
}
      

Search Form with Hybrid Approach


function SearchComponent() {
  // Controlled state for the input
  const [query, setQuery] = useState('');
  // State for search results
  const [results, setResults] = useState([]);
  // State for loading indicator
  const [isLoading, setIsLoading] = useState(false);
  // Ref for input element (for focus management)
  const inputRef = useRef(null);
  
  // Create a debounced search function
  const debouncedSearch = useCallback(
    debounce(async (searchTerm) => {
      if (!searchTerm.trim()) {
        setResults([]);
        return;
      }
      
      setIsLoading(true);
      
      try {
        // Simulate API call
        const response = await fetch(
          `https://api.example.com/search?q=${encodeURIComponent(searchTerm)}`
        );
        const data = await response.json();
        setResults(data.results);
      } catch (error) {
        console.error('Search error:', error);
        setResults([]);
      } finally {
        setIsLoading(false);
      }
    }, 300),
    []
  );
  
  // Trigger search when query changes
  useEffect(() => {
    debouncedSearch(query);
    
    // Cleanup on unmount
    return () => {
      debouncedSearch.cancel();
    };
  }, [query, debouncedSearch]);
  
  const handleChange = (e) => {
    setQuery(e.target.value);
  };
  
  const handleClear = () => {
    setQuery('');
    setResults([]);
    inputRef.current.focus();
  };
  
  // Handle result selection
  const handleResultClick = (result) => {
    console.log('Selected result:', result);
    // Implement your selection logic here
  };
  
  return (
    <div className="search-container">
      <div className="search-input-wrapper">
        <input
          type="text"
          value={query}
          onChange={handleChange}
          ref={inputRef}
          placeholder="Search..."
          className="search-input"
        />
        
        {query && (
          <button 
            onClick={handleClear}
            className="clear-button"
            type="button"
            aria-label="Clear search"
          >
            ×
          </button>
        )}
      </div>
      
      {isLoading && (
        <div className="loading-indicator">Searching...</div>
      )}
      
      {!isLoading && results.length > 0 && (
        <ul className="search-results">
          {results.map(result => (
            <li 
              key={result.id}
              onClick={() => handleResultClick(result)}
              className="result-item"
            >
              <div className="result-title">{result.title}</div>
              <div className="result-description">{result.description}</div>
            </li>
          ))}
        </ul>
      )}
      
      {!isLoading && query && results.length === 0 && (
        <div className="no-results">No results found</div>
      )}
    </div>
  );
}
      

Practice Exercises

Exercise 1: Controlled to Uncontrolled Conversion

Take a controlled form component and convert it to an uncontrolled version. Pay attention to the differences in how you handle form submission and validation.


// Starting with this controlled component:
function ControlledContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Form data:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="Message"
      />
      <button type="submit">Send</button>
    </form>
  );
}

// Convert it to an uncontrolled component
      

Exercise 2: Implement a Form with Both Approaches

Create a user registration form with the following fields:

Implement proper validation and error handling. Show how to combine both approaches in a single form.

Exercise 3: Performance Optimization

Create a large form with at least 10 fields using controlled components. Then optimize it for performance using the techniques discussed (debouncing, memoization, etc.). Compare the performance before and after optimization.

Summary

In this lecture, we've explored the two fundamental approaches to handling form components in React:

We've also learned:

Remember that neither approach is inherently "better" than the other - they are tools with different strengths. The React team generally recommends using controlled components where possible, but uncontrolled components remain useful in many scenarios.

By understanding both approaches, you can make informed decisions about how to implement forms in your React applications, balancing factors like complexity, performance, and functionality.

Further Resources