State Snapshot & Purity
State is a snapshot, not a live variable
Section titled “State is a snapshot, not a live variable”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:
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:
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.
Treat state as immutable
Section titled “Treat state as immutable”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:
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]);Render must be pure
Section titled “Render must be pure”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"]
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.
StrictMode double-renders on purpose
Section titled “StrictMode double-renders on purpose”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.)
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Updater function setX(x => ...) for values derived from previous state | Always correct even when the same setter is called more than once per event | Must be pure — no logging/mutating side effects inside it |
| New object/array on every state update | React’s Object.is bail-out and the Compiler’s memoization both work correctly | More 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 code | Only works for values that are actually reference/primitive-equal — a mutated object never qualifies |
Pure render, side effects in useEffect/handlers | Compatible with the Compiler, StrictMode, and concurrent rendering | Requires discipline: it’s easy to accidentally slip a mutation or a Date.now() into render |
Common Misconceptions
Section titled “Common Misconceptions”- “
setCount(count + 1)called three times adds 3.” It adds 1 — all three reads see the samecountsnapshot 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.isbail-out only matters for performance.” It also explains bugs — the exact same mechanism that “harmlessly” skips a redundantsetCount(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).
Reading AI-generated code
Section titled “Reading AI-generated code”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
setXhave 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 theObject.isbail-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 }))).