React State Management - Complete Guide

React State Management - Complete Guide

State is what makes React components interactive and dynamic. Understanding how to manage state effectively is crucial for building React applications. This guide covers everything you need to know about React state management.

What is State?

State is data that can change over time and affects what gets rendered on the screen. Unlike props, which are read-only and passed from parent components, state is managed within a component and can be updated using the setState function.

When state changes, React automatically re-renders the component to reflect the new data.

useState Hook

The useState hook is the most common way to add state to functional components.

Basic Syntax

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

The useState hook returns an array with two elements:

  1. The current state value
  2. A function to update that value

State with Different Data Types

String State

function NameInput() {
  const [name, setName] = useState('');
  
  return (
    <div>
      <input 
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>Hello, {name || 'Guest'}!</p>
    </div>
  );
}

Boolean State

function Toggle() {
  const [isVisible, setIsVisible] = useState(true);
  
  return (
    <div>
      <button onClick={() => setIsVisible(!isVisible)}>
        Toggle Visibility
      </button>
      {isVisible && <p>Now you see me!</p>}
    </div>
  );
}

Array State

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');
  
  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, input]);
      setInput('');
    }
  };
  
  return (
    <div>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
        placeholder="Add a todo"
      />
      <button onClick={addTodo}>Add</button>
      
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

Object State

function UserProfile() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    age: ''
  });
  
  const handleChange = (field, value) => {
    setUser(prevUser => ({
      ...prevUser,
      [field]: value
    }));
  };
  
  return (
    <div>
      <input
        type="text"
        placeholder="Name"
        value={user.name}
        onChange={(e) => handleChange('name', e.target.value)}
      />
      <input
        type="email"
        placeholder="Email"
        value={user.email}
        onChange={(e) => handleChange('email', e.target.value)}
      />
      <input
        type="number"
        placeholder="Age"
        value={user.age}
        onChange={(e) => handleChange('age', e.target.value)}
      />
      
      <div>
        <p>Name: {user.name}</p>
        <p>Email: {user.email}</p>
        <p>Age: {user.age}</p>
      </div>
    </div>
  );
}

State Update Patterns

Functional Updates

When the new state depends on the previous state, use functional updates:

function Counter() {
  const [count, setCount] = useState(0);
  
  // Bad - can cause issues with batching
  const incrementBad = () => {
    setCount(count + 1);
    setCount(count + 1);
  };
  
  // Good - uses functional update
  const incrementGood = () => {
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementGood}>Increment</button>
    </div>
  );
}

Updating Arrays

Never mutate arrays directly. Always create new arrays:

function ArrayExample() {
  const [items, setItems] = useState(['apple', 'banana']);
  
  // Add item
  const addItem = (newItem) => {
    setItems([...items, newItem]);
  };
  
  // Remove item
  const removeItem = (index) => {
    setItems(items.filter((_, i) => i !== index));
  };
  
  // Update item
  const updateItem = (index, newValue) => {
    setItems(items.map((item, i) => 
      i === index ? newValue : item
    ));
  };
  
  return (
    <div>
      <ul>
        {items.map((item, index) => (
          <li key={index}>
            {item}
            <button onClick={() => removeItem(index)}>Remove</button>
          </li>
        ))}
      </ul>
      <button onClick={() => addItem('orange')}>Add Orange</button>
    </div>
  );
}

Updating Objects

Never mutate objects directly. Always create new objects:

function ObjectExample() {
  const [user, setUser] = useState({
    name: 'John',
    address: {
      street: '123 Main St',
      city: 'New York'
    }
  });
  
  // Update top-level property
  const updateName = (newName) => {
    setUser(prevUser => ({
      ...prevUser,
      name: newName
    }));
  };
  
  // Update nested property
  const updateCity = (newCity) => {
    setUser(prevUser => ({
      ...prevUser,
      address: {
        ...prevUser.address,
        city: newCity
      }
    }));
  };
  
  return (
    <div>
      <p>Name: {user.name}</p>
      <p>City: {user.address.city}</p>
      <button onClick={() => updateName('Jane')}>Change Name</button>
      <button onClick={() => updateCity('Boston')}>Change City</button>
    </div>
  );
}

Multiple State Variables

You can use multiple useState hooks in one component:

function Form() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    setError('');
    
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
      console.log('Form submitted:', { email, password });
    } catch (err) {
      setError('Submission failed');
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      
      {error && <p className="error">{error}</p>}
      
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

State vs Props

Understanding when to use state vs props is important:

// Parent component manages state
function ParentComponent() {
  const [message, setMessage] = useState('Hello from parent');
  
  return (
    <div>
      <ChildComponent message={message} />
      <button onClick={() => setMessage('New message')}>
        Update Message
      </button>
    </div>
  );
}

// Child component receives data via props
function ChildComponent({ message }) {
  // Child cannot modify props directly
  return <p>{message}</p>;
}

Lifting State Up

When multiple components need to share state, lift it up to their common ancestor:

function TemperatureConverter() {
  const [celsius, setCelsius] = useState('');
  const [fahrenheit, setFahrenheit] = useState('');
  
  const handleCelsiusChange = (value) => {
    setCelsius(value);
    if (value === '') {
      setFahrenheit('');
    } else {
      const f = (parseFloat(value) * 9/5) + 32;
      setFahrenheit(f.toFixed(2));
    }
  };
  
  const handleFahrenheitChange = (value) => {
    setFahrenheit(value);
    if (value === '') {
      setCelsius('');
    } else {
      const c = (parseFloat(value) - 32) * 5/9;
      setCelsius(c.toFixed(2));
    }
  };
  
  return (
    <div>
      <CelsiusInput 
        value={celsius} 
        onChange={handleCelsiusChange} 
      />
      <FahrenheitInput 
        value={fahrenheit} 
        onChange={handleFahrenheitChange} 
      />
    </div>
  );
}

function CelsiusInput({ value, onChange }) {
  return (
    <div>
      <label>Celsius:</label>
      <input 
        type="number" 
        value={value} 
        onChange={(e) => onChange(e.target.value)}
      />
    </div>
  );
}

function FahrenheitInput({ value, onChange }) {
  return (
    <div>
      <label>Fahrenheit:</label>
      <input 
        type="number" 
        value={value} 
        onChange={(e) => onChange(e.target.value)}
      />
    </div>
  );
}

State Management Best Practices

1. Keep State Minimal

Only store in state what you need for rendering:

// Bad - storing derived data
function UserComponent() {
  const [user, setUser] = useState({ name: 'John', age: 25 });
  const [displayName, setDisplayName] = useState(''); // Derived!
  
  useEffect(() => {
    setDisplayName(`${user.name} (${user.age})`);
  }, [user]);
  
  return <p>{displayName}</p>;
}

// Good - derive when needed
function UserComponent() {
  const [user, setUser] = useState({ name: 'John', age: 25 });
  
  const displayName = `${user.name} (${user.age})`; // Derived on render
  
  return <p>{displayName}</p>;
}

2. Use Multiple State Variables for Unrelated Data

// Good - separate concerns
function UserProfile() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  
  // ...
}

3. Group Related State

// Good - related data together
function FormComponent() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  
  const [formStatus, setFormStatus] = useState({
    isLoading: false,
    error: '',
    success: false
  });
  
  // ...
}

Common State Management Patterns

1. Form State Pattern

function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
  };
  
  const handleSubmit = async (onSubmit) => {
    setIsSubmitting(true);
    setErrors({});
    
    try {
      await onSubmit(values);
    } catch (error) {
      setErrors(error);
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit
  };
}

function ContactForm() {
  const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm({
    name: '',
    email: '',
    message: ''
  });
  
  const onSubmit = async (formData) => {
    // Submit form data
    console.log('Submitting:', formData);
  };
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      handleSubmit(onSubmit);
    }}>
      <input
        name="name"
        value={values.name}
        onChange={handleChange}
        placeholder="Name"
      />
      {errors.name && <span className="error">{errors.name}</span>}
      
      <input
        name="email"
        value={values.email}
        onChange={handleChange}
        placeholder="Email"
      />
      {errors.email && <span className="error">{errors.email}</span>}
      
      <textarea
        name="message"
        value={values.message}
        onChange={handleChange}
        placeholder="Message"
      />
      {errors.message && <span className="error">{errors.message}</span>}
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

2. Toggle Pattern

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = () => setValue(prev => !prev);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);
  
  return [value, { toggle, setTrue, setFalse }];
}

function Modal() {
  const [isOpen, { toggle, setTrue, setFalse }] = useToggle(false);
  
  return (
    <div>
      <button onClick={toggle}>Open Modal</button>
      
      {isOpen && (
        <div className="modal">
          <div className="modal-content">
            <h2>Modal Title</h2>
            <p>Modal content goes here.</p>
            <button onClick={setFalse}>Close</button>
          </div>
        </div>
      )}
    </div>
  );
}

3. Async Data Pattern

function useAsync(asyncFunction, dependencies = []) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let isCancelled = false;
    
    const fetchData = async () => {
      try {
        setLoading(true);
        const result = await asyncFunction();
        
        if (!isCancelled) {
          setData(result);
          setError(null);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err);
          setData(null);
        }
      } finally {
        if (!isCancelled) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    return () => {
      isCancelled = true;
    };
  }, dependencies);
  
  return { data, loading, error };
}

function UserList() {
  const { data: users, loading, error } = useAsync(
    () => fetch('/api/users').then(res => res.json()),
    []
  );
  
  if (loading) return <div>Loading users...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

When to Consider State Management Libraries

While React’s built-in state management is great for most applications, you might need external libraries for:

  1. Complex state logic - When state updates become complex
  2. Shared state - When many components need the same state
  3. Performance optimization - When you need fine-grained control over re-renders
  4. Time-travel debugging - When you need to track state changes

Popular options include:

  • Redux - Predictable state container
  • Zustand - Minimal state management
  • Recoil - Facebook’s experimental state management
  • Context API - React’s built-in solution for global state

Summary

State management is a core React concept. Remember these key points:

  • Use useState for local component state
  • Never mutate state directly - always create new objects/arrays
  • Use functional updates when new state depends on previous state
  • Lift state up when components need to share data
  • Keep state minimal and derive values when possible
  • Group related state and separate unrelated concerns
  • Consider custom hooks for reusable state logic

Mastering state management will help you build more maintainable and scalable React applications.


External Resources:

Related Tutorials:

Last updated on