Skip to content

State Snapshot & Purity

This is the single most misunderstood thing in React. When you read a state variable, you are reading its value for this render — a snapshot frozen at the moment the component was called. It does not change mid-render, even after you call the setter.

function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count is 0 for this whole render. All three read the SAME snapshot (0),
// so this sets state to 0 + 1 = 1, three times. Result: 1, not 3.
}
return <button onClick={handleClick}>{count}</button>;
}

To update based on the latest pending value, pass an updater function — React runs them in order against the evolving value:

setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// Each receives the previous pending value: 0→1→2→3. Result: 3.

The mental model: a re-render is a fresh call of your function with a new snapshot. The old render’s variables are gone; the new render sees the new values. That’s true even for values captured inside a closure that outlives the render it was created in — a classic case is scheduling something asynchronously from inside an event handler:

src/__tests__/state-is-a-snapshot.test.tsx
import { useState } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a closure created during a render keeps reading THAT render\'s snapshot, even after state changes later', async () => {
const seen: number[] = [];
function Delayed() {
const [count, setCount] = useState(0);
function logLater() {
// Closes over `count` from THIS render — not a live reference.
setTimeout(() => seen.push(count), 0);
}
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>inc</button>
<button onClick={logLater}>log</button>
</div>
);
}
const { getAllByRole } = render(<Delayed />);
const [inc, log] = getAllByRole('button');
fireEvent.click(log); // schedules a log of count = 0 (this render's snapshot)
fireEvent.click(inc); // count becomes 1 — but the ALREADY-SCHEDULED closure still has 0
await new Promise((resolve) => setTimeout(resolve, 10));
expect(seen).toEqual([0]);
});
✓ a closure created during a render keeps reading THAT render's snapshot, even after state changes later
Test Files 1 passed (1)
Tests 1 passed (1)

Object.is bail-out: setting the same value skips the render

Section titled “Object.is bail-out: setting the same value skips the render”

React decides whether a setState call needs a new render by comparing the new value to the current one with Object.is (the same-value comparison used by ===, with the NaN/-0 edge cases fixed). If they’re the same, React bails out and skips re-rendering that component entirely — the call isn’t wasted work turned into a no-op render, it’s a genuinely skipped render:

src/__tests__/object-is-bailout.test.tsx
import { useState } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('setCount(sameValue) does not cause an extra render', () => {
let renders = 0;
function Counter() {
const [count, setCount] = useState(5);
renders++;
return <button onClick={() => setCount(5)}>{count}</button>;
}
const { getByRole } = render(<Counter />);
const baseline = renders;
fireEvent.click(getByRole('button')); // setCount(5) — identical to the current 5
expect(renders).toBe(baseline);
});
test('setCount(differentValue) DOES cause a render, for contrast', () => {
let renders = 0;
function Counter() {
const [count, setCount] = useState(5);
renders++;
return <button onClick={() => setCount(6)}>{count}</button>;
}
const { getByRole } = render(<Counter />);
const baseline = renders;
fireEvent.click(getByRole('button'));
expect(renders).toBe(baseline + 1);
});
✓ setCount(sameValue) does not cause an extra render
✓ setCount(differentValue) DOES cause a render, for contrast
Test Files 1 passed (1)
Tests 2 passed (2)

This is exactly the same comparison React uses to decide whether mutating state “worked” — which is why the next section’s bug is so easy to write by accident.

Because state is a snapshot React compares by identity, you must never mutate it. Mutating an object or array in place keeps the same reference — so the Object.is check above sees no change and bails out, even though the underlying data really did change:

src/__tests__/mutation-does-not-rerender.test.tsx
import { useState } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('mutating state then calling the setter with the SAME reference leaves the UI stale', () => {
let renders = 0;
function TodoList() {
const [todos, setTodos] = useState<string[]>(['a']);
renders++;
return (
<div>
<button
onClick={() => {
todos.push('b'); // mutates the existing array in place
setTodos(todos); // same reference as before — React bails out
}}
>
mutate
</button>
<span>{todos.length}</span>
</div>
);
}
const { getByRole, container } = render(<TodoList />);
const baseline = renders;
fireEvent.click(getByRole('button'));
expect(renders).toBe(baseline); // no re-render happened
expect(container.querySelector('span')!.textContent).toBe('1'); // UI still says 1, even though the array is secretly length 2
});
test('spreading into a NEW array (different reference) re-renders correctly, for contrast', () => {
function TodoList() {
const [todos, setTodos] = useState<string[]>(['a']);
return (
<div>
<button onClick={() => setTodos([...todos, 'b'])}>add</button>
<span>{todos.length}</span>
</div>
);
}
const { getByRole, container } = render(<TodoList />);
fireEvent.click(getByRole('button'));
expect(container.querySelector('span')!.textContent).toBe('2');
});
✓ mutating state then calling the setter with the SAME reference leaves the UI stale
✓ spreading into a NEW array (different reference) re-renders correctly, for contrast
Test Files 1 passed (1)
Tests 2 passed (2)

The mutated version doesn’t just look wrong later — it silently diverges from that instant: todos really is length 2 in memory, but the screen (and any future render, until something else forces one) keeps showing 1. Always produce a new reference:

// ❌ Mutation — same array reference, React may not re-render, and it's a bug.
todos.push(newTodo);
setTodos(todos);
// ✅ New reference — React sees a change and re-renders.
setTodos([...todos, newTodo]);

A component’s render — everything it does while computing its returned JSX — must be pure: given the same props and state, it returns the same output and causes no side effects. During render you must not:

  • mutate props, state, or any pre-existing variable/object;
  • write to the DOM, make network requests, or start timers;
  • read or write anything outside the function that could change.
flowchart LR
  inputs["props + state
(snapshot)"] --> render["render (pure):
compute JSX only"]
  render --> out["same inputs → same JSX"]
  render -. NOT here .-> se["side effects:
DOM, network, timers"]
  se --> eff["→ event handlers & effects"]
Pure render in, side effects out

Side effects belong in event handlers (things that happen on interaction) and effects (useEffect, for synchronizing with external systems) — never in the render body. This purity requirement isn’t just a style preference: the React Compiler auto-memoizes your components and hooks by assuming render is pure. If render secretly mutates something or reads a value that can change between calls, the compiler’s memoization can serve a stale or wrong cached result, because it never re-runs code it has decided the inputs didn’t change for.

In development, React’s <StrictMode> deliberately calls your component function twice (and runs effects setup→cleanup→setup). This is not a bug — it’s a detector. If your render is pure, calling it twice produces identical output and nothing breaks. If you accidentally mutate something or rely on a side effect during render, the double-invocation surfaces the bug immediately, in development, instead of as a mysterious glitch in production. (The mount/unmount and reorder side of reconciliation — including StrictMode’s effect-level double-invoke — is covered in detail in the previous lesson, Rendering & Reconciliation.)

Every render gets its own frozen copy of props and state — think of it as a photograph, not a live video feed. A setState call doesn’t retouch the photograph you’re holding; it asks React to take a new one. Object.is is the check React uses to decide “is the new photograph actually different from the last one, or can I skip developing it?” — and that check only ever looks at reference identity for objects/arrays, never at their contents. Purity is what makes any of this trustworthy: if pointing the camera at the same scene could produce a different photo depending on hidden side effects, no bail-out, no memoization, and no StrictMode check would mean anything.

ChoiceBenefitCost
Updater function setX(x => ...) for values derived from previous stateAlways correct even when the same setter is called more than once per eventMust be pure — no logging/mutating side effects inside it
New object/array on every state updateReact’s Object.is bail-out and the Compiler’s memoization both work correctlyMore allocations than mutating in place (usually irrelevant; occasionally worth a library like Immer for deeply nested state)
Relying on the Object.is bail-out (e.g. setCount(sameValue))Skips wasted renders “for free,” no extra codeOnly works for values that are actually reference/primitive-equal — a mutated object never qualifies
Pure render, side effects in useEffect/handlersCompatible with the Compiler, StrictMode, and concurrent renderingRequires discipline: it’s easy to accidentally slip a mutation or a Date.now() into render
  • “setCount(count + 1) called three times adds 3.” It adds 1 — all three reads see the same count snapshot from this render. Proven above with a failing/passing pair.
  • “Mutating an object and calling the setter with the same reference will still just work, it’s just against best practice.” It can silently fail to re-render, because React’s bail-out check is a reference comparison (Object.is) — proven above: the array becomes length 2 in memory but the UI keeps showing 1.
  • “Object.is bail-out only matters for performance.” It also explains bugs — the exact same mechanism that “harmlessly” skips a redundant setCount(5) is what silently swallows a mutated array/object update.
  • “Purity is a style guideline, not something enforced.” The React Compiler’s memoization actively depends on it — an impure render can make the Compiler serve a stale cached result, not just “look messy.”
  • “StrictMode’s double-render means my component runs twice in production too.” Development-only — production always renders and runs effects exactly once (see the effect-timing proof in the reconciliation lesson).

When a generated component updates state, check for these before trusting it:

  • Is an object or array being mutated in place (.push, .sort(), direct property assignment) before being passed to the setter, instead of spreading into a new reference? The bug is invisible until you notice the UI didn’t update.
  • Is the same setter called more than once in one function using the value form (setX(x + 1)) instead of the updater form? All calls see the same snapshot.
  • Does an updater function passed to setX have side effects (logging, network calls, mutating an outer variable)? Purity is required, and StrictMode’s double-invoke in development will call it twice.
  • Does any code inside the render body (not inside an event handler or useEffect) read something that can change between calls — Date.now(), Math.random(), a mutable module-level variable — or write to the DOM directly?
  • Does a call like setCount(sameValueAsNow) get treated as “definitely triggers a render,” when it’s actually a no-op due to the Object.is bail-out?

🐛 Spot the bug

function Cart({ initialItems }) {
const [items, setItems] = useState(initialItems);
function markAllPacked() {
items.forEach((item) => { item.packed = true; });
setItems(items);
}
return (
<div>
<button onClick={markAllPacked}>Mark all packed</button>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name} — {item.packed ? 'packed' : 'not packed'}</li>
))}
</ul>
</div>
);
}
Show the answer

markAllPacked mutates every item in place (item.packed = true) and then calls setItems(items) with the same array reference it already had. React’s Object.is check sees the identical reference and bails out — no re-render happens. Proven above with the equivalent minimal case: the underlying data really changed (every item now has packed: true in memory), but the screen keeps showing “not packed” until some unrelated state change forces a re-render, at which point it will suddenly show the already-mutated data, looking like a random, unrelated update caused it. The fix is a new array and new item objects: setItems(items.map((item) => ({ ...item, packed: true }))).

Inside one event handler, setCount(count + 1) is called three times with count = 0. What is the result?
How do you correctly increment state three times in one handler?
Why must you not mutate state (e.g. todos.push(x); setTodos(todos))?
What is StrictMode’s double-render for?
How many extra renders does clicking this button cause?
function Counter() {
  const [count, setCount] = useState(5);
  return <button onClick={() => setCount(5)}>{count}</button>;
}
After calling this, does the UI immediately show every item as packed?
function markAllPacked() {
  items.forEach((item) => { item.packed = true; });
  setItems(items);
}