Managing State with useState

State updates, batching, and why you update state instead of mutating it.

What you'll learn

  • Why a plain variable cannot drive the UI
  • Reading and setting state, and when the new value arrives
  • Updating state from the previous value
  • Changing objects and arrays in state without mutating them

A variable is not state

React re-renders a component when its state changes. A plain variable is not state: you can change it, but nothing tells React to run the component again, so the screen keeps showing the old value.

Both buttons below increment a number. Only one of them tells React:

import { useState } from "react";

export default function App() {
  let plain = 0;
  const [count, setCount] = useState(0);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>plain variable: {plain}</p>
      <button onClick={() => { plain = plain + 1; console.log("plain is now", plain); }}>
        Increment variable
      </button>

      <p style={{ marginTop: 16 }}>state: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment state</button>
    </div>
  );
}

The console proves plain really does change — the screen just never hears about it. And even if something else re-rendered the component, plain would be reset to 0, because the function runs again from the top.

State solves both halves: React remembers the value between renders, and changing it schedules a re-render.

Reading and setting

useState returns a pair, which you unpack with array destructuring:

const [count, setCount] = useState(0);
  • count is the value for this render. It never changes during a render.
  • setCount asks React for another render with a new value.
  • The argument to useState is the initial value, used only on the first render.

State is not updated immediately

This is the part that surprises everyone. Setting state does not change the count variable you are holding — it schedules a new render. Reading count straight after setCount gives you the old value:

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log("right after setCount, count is still", count);
  }

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>{count}</p>
      <button onClick={handleClick}>Increment and log</button>
    </div>
  );
}

count is a const inside a function call. React calls your function again with a new value; it cannot reach into the call that already happened.

Three increments, one step

The consequence: calling the setter three times with the same stale value sets the same number three times.

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>{count}</p>

      <button onClick={() => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }}>
        Add three (broken)
      </button>

      <button
        onClick={() => { setCount((c) => c + 1); setCount((c) => c + 1); setCount((c) => c + 1); }}
        style={{ marginLeft: 8 }}
      >
        Add three (updater)
      </button>
    </div>
  );
}

The first button adds one. All three calls compute 0 + 1.

The second adds three. Passing a function to the setter means "given the latest value, here is the next one", so React applies them in sequence. Reach for the updater form whenever the next value depends on the previous one.

Objects and arrays

State follows the copying rule from Destructuring, Spread, and Rest: React compares by identity, so mutating an object in place leaves it the same object, and React concludes nothing changed.

import { useState } from "react";

export default function App() {
  const [user, setUser] = useState({ name: "Ada", role: "engineer" });
  const [tags, setTags] = useState(["intro"]);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>{user.name}{user.role}</p>
      <p>tags: {tags.join(", ")}</p>

      <button onClick={() => { user.role = "lead"; setUser(user); }}>
        Mutate (nothing happens)
      </button>

      <button onClick={() => setUser({ ...user, role: "lead" })} style={{ marginLeft: 8 }}>
        Copy (works)
      </button>

      <button onClick={() => setTags([...tags, "react"])} style={{ marginLeft: 8 }}>
        Add tag
      </button>
    </div>
  );
}

The first button changes the data and the screen does not move — the object handed back is the one React already has. The second passes a new object, so React re-renders.

For arrays, use the methods that return a new array: [...tags, "react"] to add, filter to remove, map to change one item.

Where state should live

Each call to useState belongs to one component instance. Two <Counter />s have two independent counts. When two components need the same value, it belongs in their closest common parent — the subject of Lifting State Up.

Common mistakes

  • Reading state right after setting it. You get the old value; the new one arrives on the next render.
  • setCount(count + 1) several times. Use setCount(c => c + 1).
  • Mutating an object or array in state. Copy it instead.
  • Calling useState in a condition or loop. Hooks run in the same order on every render — see Writing Custom Hooks.

Exercise

Two bugs. "Add two" only adds one, and "Rename" does nothing. Fix both.

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState({ name: "Ada" });

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>count: {count}</p>
      <button onClick={() => { setCount(count + 1); setCount(count + 1); }}>
        Add two
      </button>

      <p style={{ marginTop: 16 }}>name: {user.name}</p>
      <button onClick={() => { user.name = "Grace"; setUser(user); }}>Rename</button>
    </div>
  );
}