AniUI Academy

useState and State Updates

Giving a component its own memory with useState, why state updates trigger a re-render, the functional-update form, and why React batches multiple updates together.

10 min read

Every component you've seen so far has been static — it renders the same thing every time. useState is the hook that gives a component its own memory: a value that persists between renders, and that causes a re-render whenever it changes.

The basic shape

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

useState(0) does two things: it initializes the state to 0 on the component's first render, and it returns a pair — the current value (count) and a function to update it (setCount). Calling setCount schedules React to re-run this component's function with the new value, which is what makes the button's text update.

The 0 argument is only used on the very first render. On every render after that, useState returns whatever was last set, ignoring the initial value entirely — the same way a variable's initializer only runs once.

State doesn't change mid-render

This is the detail that trips people up first: calling the setter doesn't change the variable right there in the function. count inside a given render is a plain value, captured for that render, and it stays that value for the rest of that render's execution — even immediately after calling setCount.

function handleClick() {
  setCount(count + 1);
  console.log(count); // still the OLD value — this render hasn't re-run yet
}

The update takes effect the next time the component renders, not synchronously at the call site.

The batching trap, and its fix

Because setCount(count + 1) reads count from the current render's closure, calling it multiple times in the same event handler doesn't compound the way you'd intuitively expect:

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // count only ends up +1, not +3 — all three read the SAME count
}

All three calls happen before React re-renders, so all three read the identical count from this render and all three say "set it to that same value plus one." The fix is the functional update form — pass a function instead of a value, and React calls it with whatever the state will actually be at that point, including earlier updates already queued in the same batch:

function handleTripleClick() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  // now correctly +3
}

A good habit: reach for the functional form any time the new state depends on the previous state, rather than on some independent value.

Updates are batched by design

React groups multiple setState calls that happen within the same event handler (and, since React 18, within promises, timeouts, and native event handlers too — "automatic batching") into a single re-render, rather than re-rendering after each one individually:

function handleClick() {
  setLoading(false);
  setError(null);
  setData(newData);
  // one re-render for all three, not three separate re-renders
}

This is a performance win you get for free — three state updates that belong together produce one render pass, not three.

State holding objects and arrays

useState can hold any value, including objects and arrays — but React decides whether to re-render by checking if the new value is a different reference from the old one (Object.is comparison), not by deep-comparing contents. That means state must be replaced with a new object or array, not mutated in place:

// Wrong — same array reference, may not trigger a re-render
function addItem(item) {
  items.push(item);
  setItems(items);
}
 
// Right — a new array reference
function addItem(item) {
  setItems([...items, item]);
}

This is the same "don't mutate, make a copy" habit from the JavaScript track's arrays-and-objects lesson — in React it's not just good practice, it's the difference between an update working and silently doing nothing.

Try it yourself

Try it yourself
Loading playground...

What to remember

  • useState returns a current value and a setter; calling the setter schedules a re-render with the new value, it doesn't mutate anything on the spot.
  • A render's state variable is a fixed snapshot for that render — it doesn't change again until the next render actually happens.
  • When new state depends on old state, use the functional update form (setX(x => ...)) so multiple queued updates compound correctly.
  • React batches state updates within an event handler into one re-render, and — since React 18 — across promises and timeouts too.
  • Never mutate state directly; create a new object or array so React's reference check notices the change.

Check yourself

4 questions · pass 3/4 to unlock Controlled Forms

up to 50
  1. 1.What does calling setCount(count + 1) actually do?

  2. 2.Why does this code only ever increment by 1, no matter how many times increment() is called inside one event handler? function increment() { setCount(count + 1); } function handleClick() { increment(); increment(); increment(); }

  3. 3.What fixes the previous problem, so calling increment() three times actually adds 3?

  4. 4.Why must you write setItems([...items, newItem]) rather than items.push(newItem); setItems(items);?

4 left to answer