React useState Hook

Manage React component state with useState to store and update values across renders.

TL;DR

  1. 01Declare state with useState and get back value and setter.
  2. 02Call setter to update state, triggering a re-render.
  3. 03Use functional updates for state based on previous state.

Tips

  1. 01Use functional updates (prev => ...) when the new state depends on the old state — it's safer and more readable.

Warnings

  1. 01Never mutate state directly — always create new objects and arrays to trigger re-renders properly.

Basic useState

  • Declare state with initial value.
    const [count, setCount] = useState(0);
  • count is the current value, setCount updates and re-renders.
    function Counter() {
      const [count, setCount] = useState(0);
      
      return (
        <>
          <p>Count: {count}</p>
          <button onClick={() => setCount(count + 1)}>
            Increment
          </button>
        </>
      );
    }
  • Each state variable needs its own useState call.
  • Initialize state with strings, numbers, booleans, arrays, or objects.
    const [name, setName] = useState("");
    const [isOpen, setIsOpen] = useState(false);
    const [items, setItems] = useState([]);
    const [user, setUser] = useState(null);
  • Calling the setter always replaces the value, never merges.
    // Calling setCount replaces the entire value
    setCount(42); // count is now 42, not 42 + oldCount

Multiple State Variables

  • Declare multiple state variables separately.
    function Form() {
      const [name, setName] = useState("");
      const [email, setEmail] = useState("");
      const [age, setAge] = useState(0);
      
      return (
        <>
          <input value={name} onChange={(e) => setName(e.target.value)} />
          <input value={email} onChange={(e) => setEmail(e.target.value)} />
          <input value={age} onChange={(e) => setAge(e.target.value)} />
        </>
      );
    }
  • Group related state into a single object.
    const [form, setForm] = useState({
      name: "",
      email: "",
      age: 0
    });
  • Update a single field in an object with spread syntax.
    const handleChange = (field) => (e) => {
      setForm(prev => ({ ...prev, [field]: e.target.value }));
    };
  • Keep unrelated state variables separate for clarity.
    // These are unrelated — keep them separate
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);
    const [data, setData] = useState(null);
  • Use useReducer when multiple variables always update together.
    // If isLoading, error, and data always change at once
    // consider useReducer instead of three useState calls

Functional Updates

  • Use a function to update state based on previous value.
    function Counter() {
      const [count, setCount] = useState(0);
      
      // Direct update (fine for simple cases)
      // setCount(count + 1);
      
      // Functional update (preferred for dependent updates)
      return <button onClick={() => setCount(prev => prev + 1)}>Increment</button>;
    }
  • Functional updates are safer when multiple updates happen fast.
    const handleClick = () => {
      setCount(prev => prev + 1);
      setCount(prev => prev + 1);
      // Both increments use the latest state — count increases by 2
    };
  • Use functional updates inside useEffect and useCallback.
    useEffect(() => {
      const timer = setInterval(() => {
        setCount(prev => prev + 1); // safe inside interval
      }, 1000);
      return () => clearInterval(timer);
    }, []); // no count in deps needed
  • Functional updates work with arrays too.
    function addItem(newItem) {
      setItems(prev => [...prev, newItem]);
    }
    
    function removeItem(id) {
      setItems(prev => prev.filter(item => item.id !== id));
    }
  • Direct updates are fine when the new value doesn't depend on old state.
    // setName doesn't need the previous name
    setName("Alice");
    setIsOpen(true);

Lazy Initialization

  • Initialize state from an expensive computation using a function.
    const [state, setState] = useState(() => {
      return expensiveComputation();
    });
  • Function runs only on mount, not on every render.
    const [todos, setTodos] = useState(() => {
      const saved = localStorage.getItem("todos");
      return saved ? JSON.parse(saved) : [];
    });
  • Useful for loading from localStorage or parsing initial data.
  • Without lazy initialization, the function runs on every render.
    // Bad: expensiveComputation() runs on every render
    const [state, setState] = useState(expensiveComputation());
    
    // Good: arrow function defers the call to mount only
    const [state, setState] = useState(() => expensiveComputation());
  • Pass the function itself, not the result of calling it.
    // Correct: pass a function
    useState(() => computeInitialValue())
    
    // Wrong: calls the function immediately
    useState(computeInitialValue())

Updating Objects and Arrays

  • Create a new object when updating — never mutate state directly.
    const [user, setUser] = useState({ name: "Alice", age: 30 });
    
    // Wrong: mutation — React won't detect the change
    user.age = 31;
    
    // Right: create new object with spread
    setUser({ ...user, age: 31 });
  • Create a new array for all array state operations.
    const [items, setItems] = useState(["a", "b", "c"]);
    
    // Add item
    setItems([...items, "d"]);
    
    // Remove item
    setItems(items.filter(item => item !== "b"));
  • Update a nested object by spreading at every level.
    const [profile, setProfile] = useState({ name: "Alice", address: { city: "NYC" } });
    
    // Update nested field
    setProfile(prev => ({
      ...prev,
      address: { ...prev.address, city: "LA" }
    }));
  • Update a specific item in an array by mapping over it.
    const [tasks, setTasks] = useState([{ id: 1, done: false }]);
    
    function toggleTask(id) {
      setTasks(prev =>
        prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
      );
    }
  • Use the Immer library for complex nested updates.
    import produce from "immer";
    
    setProfile(produce(draft => {
      draft.address.city = "LA"; // direct mutation is safe inside produce
    }));

FAQ