webdev.complete
🪝 State & Effects
⚛️React
Lesson 80 of 117
25 min

useState & Updater

Batching, lazy init, immutable updates, the updater function.

State is anything a component needs to rememberbetween renders. A counter, a form value, whether a menu is open. Plain variables won't work because a component function runs from the top every time, so any local variable resets. useState is React's answer: give me a value that survives renders, and a function to change it.

The basic shape

tsx
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

useState(0) returns a tuple. First slot is the current value. Second slot is a function that schedules a re-render with the new value. Calling setCount doesn't change countin the current render. It tells React: "next time you run this function, use this value instead."

State is a snapshot, not a live wire
Inside a single render, countis just a number that won't change. If you call setCount(count + 1) three times in a row, you set it to count + 1three times. That's still just one. Read on for the fix.

The updater function: when state goes stale

Because state is captured per render, doing this is a bug:

tsx
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1); // count is still the old number all three times
}

Pass a function instead and React calls it with the latest value:

tsx
function handleClick() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1); // now it really adds 3
}

Rule of thumb: if the new state depends on the old state, use the function form. If it doesn't (you're just replacing the value), the direct form is fine and more readable.

Automatic batching

In React 18+, multiple setState calls inside the same event (or promise, or timeout) are batchedinto a single re-render. This is good for performance, but it's also why the bug above is so easy to write.

tsx
function handleClick() {
  setName("Ada");
  setAge(36);
  setIsCoder(true);
  // ONE re-render, not three. React waits until your handler finishes.
}

Lazy initial state

useState(expensiveCalc()) calls expensiveCalc() on everyrender. React ignores the result after the first render, but the call still happens. If it's expensive (parsing large JSON, reading localStorage), pass a function:

tsx
// Bad: parseStoredTodos() runs every render
const [todos, setTodos] = useState(parseStoredTodos());

// Good: parseStoredTodos() runs once, the first render only
const [todos, setTodos] = useState(() => parseStoredTodos());

Immutable updates for objects and arrays

React decides whether to re-render by comparing the new state to the old with Object.is. If you mutate an object in place, the reference is the same. React sees no change. Nothing renders.

tsx
const [user, setUser] = useState({ name: "Ada", age: 36 });

// WRONG: mutates the old object
user.age = 37;
setUser(user); // React: "same object, skip"

// RIGHT: make a new object
setUser({ ...user, age: 37 });

// Same idea for arrays
const [items, setItems] = useState([1, 2, 3]);

// WRONG
items.push(4);
setItems(items);

// RIGHT
setItems([...items, 4]);
setItems(items.filter((i) => i !== 2));
setItems(items.map((i) => i * 2));
The spread + replace pattern
For deep updates, spread the parent, then override the changed field. For arrays, use map, filter, and concat instead of push, splice, and direct index assignment. Or reach for Immer (a library) if your state is deeply nested.

Try it: counter and list

Two classic examples. The counter shows the updater function fixing stale state. The list shows immutable array updates. Try removing the spread and see what breaks.

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  function tripleSlow() {
    // Each call sees the original count. Adds 1, not 3.
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  function tripleFast() {
    // Each call sees the in-flight value. Adds 3.
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  }

  return (
    <div style={{ marginBottom: 24 }}>
      <h3>Counter: {count}</h3>
      <button onClick={tripleSlow}>+3 (broken, adds 1)</button>{" "}
      <button onClick={tripleFast}>+3 (works)</button>{" "}
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

function TodoList() {
  const [items, setItems] = useState(["Learn useState"]);
  const [text, setText] = useState("");

  function addItem() {
    if (!text.trim()) return;
    setItems([...items, text]);
    setText("");
  }

  function removeAt(index) {
    setItems(items.filter((_, i) => i !== index));
  }

  return (
    <div>
      <h3>Todos</h3>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="New todo..."
      />{" "}
      <button onClick={addItem}>Add</button>
      <ul>
        {items.map((item, i) => (
          <li key={i}>
            {item}{" "}
            <button onClick={() => removeAt(i)}>x</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <Counter />
      <TodoList />
    </div>
  );
}

Quiz

Quiz1 / 3

You write setCount(count + 1) three times in a click handler. count starts at 0. What is it after the click?

Recap

  • useState gives you a value + setter that survive renders.
  • State updates are scheduled, not immediate. The current render sees the old value.
  • For state-depends-on-state, use the function form setX(prev => new).
  • React batches multiple setters in the same event into one re-render.
  • Treat objects and arrays as immutable. Spread, map, filter. Never mutate.
  • Lazy initial state with a function avoids re-running expensive setup every render.