React · lesson 4 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Also part of: Next.js
State with useState
Render components with Vitest and Testing Library, click a real button with user-event, and prove two setState calls in one handler cause one render.
By SkillByExample · Published
useState only works inside a component that React is actually rendering, so testing it means rendering the component for real — into jsdom, with Testing Library and user-event simulating a real click.
Project Setup
One more project, alongside the tsx-rendered one from the earlier lessons. package.json’s relevant devDependencies:
// package.json (devDependencies)
{
"vitest": "5.0.5",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@testing-library/jest-dom": "7.0.1",
"@vitejs/plugin-react": "6.1.2",
"jsdom": "30.1.3"
}
// vitest.config.mjs
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.js"],
},
});
// vitest.setup.js
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
afterEach(() => {
cleanup();
});
cleanup() unmounts whatever the previous test rendered, so one test’s DOM doesn’t leak into the next — without it, two tests rendering a button with the same name make getByRole ambiguous. Every output below is npx vitest run --reporter=verbose with NO_COLOR=1 set, for plain text instead of ANSI colour codes, run from /root/react-app inside Docker’s node:24.18.1 image.
A Stale Value in a Closure
A handler reads whatever count was when that render created it — calling setCount twice doesn’t change that.
// src/use-state/Counter.tsx
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
function addTwoStale() {
setCount(count + 1);
setCount(count + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={addTwoStale}>+2 (stale)</button>
</div>
);
}
// src/use-state/Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter.js";
describe("Counter", () => {
it("only goes up by one when two stale setCount calls run in one click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "+2 (stale)" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
$ npx vitest run use-state/Counter.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/use-state/Counter.test.tsx > Counter > only goes up by one when two stale setCount calls run in one click 118ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:38:03
Duration 33.59s (environment 68%, setup 21%, import 9%)
Both calls computed count + 1 from the same count — the one from the render that’s currently on screen — so the second call just schedules the identical value the first one did. The Duration line’s numbers vary run to run; the test result above it doesn’t.
The Updater Function, and One Render for Two Calls
Pass a function to setCount instead, and each call receives the latest pending value — and both calls inside one handler still cause exactly one re-render.
// src/use-state/CounterFixed.tsx
import { useState } from "react";
let renders = 0;
export function CounterFixed() {
renders++;
const [count, setCount] = useState(0);
function addTwo() {
console.log(`renders before click: ${renders}`);
setCount((c) => c + 1);
setCount((c) => c + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={addTwo}>+2 (fixed)</button>
</div>
);
}
export function getRenders() {
return renders;
}
// src/use-state/CounterFixed.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CounterFixed, getRenders } from "./CounterFixed.js";
describe("CounterFixed", () => {
it("goes up by two, and the two setCount calls cause one render", async () => {
const user = userEvent.setup();
render(<CounterFixed />);
const rendersAfterMount = getRenders();
await user.click(screen.getByRole("button", { name: "+2 (fixed)" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
expect(getRenders()).toBe(rendersAfterMount + 1);
});
});
$ npx vitest run use-state/CounterFixed.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/use-state/CounterFixed.test.tsx > CounterFixed > goes up by two, and the two setCount calls cause one render
renders before click: 1
✓ src/use-state/CounterFixed.test.tsx > CounterFixed > goes up by two, and the two setCount calls cause one render 142ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:38:40
Duration 37.39s (environment 69%, setup 19%, import 11%, transform 1%)
renders is a plain module-level counter, incremented at the top of the component function — a reliable stand-in for “how many times did React call this component.” The log inside the click handler fires before either setCount call and reads 1 (the mount render). getRenders() after the click is rendersAfterMount + 1, not + 2: React batched both setCount calls from the one handler into a single additional render, even though the click triggered two separate state updates.
Practice
1. Build a counter with both a +1 and a -1 button, sharing one count state.
Solution
// src/use-state/practice/CounterWithDecrement.tsx
import { useState } from "react";
export function CounterWithDecrement() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<button onClick={() => setCount((c) => c - 1)}>-1</button>
</div>
);
}
// src/use-state/practice/CounterWithDecrement.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CounterWithDecrement } from "./CounterWithDecrement.js";
it("adds and subtracts with two buttons sharing one piece of state", async () => {
const user = userEvent.setup();
render(<CounterWithDecrement />);
await user.click(screen.getByRole("button", { name: "+1" }));
await user.click(screen.getByRole("button", { name: "+1" }));
await user.click(screen.getByRole("button", { name: "-1" }));
console.log("screen text after +1, +1, -1:", screen.getByRole("paragraph").textContent);
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
$ npx vitest run use-state/practice/CounterWithDecrement.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/use-state/practice/CounterWithDecrement.test.tsx > adds and subtracts with two buttons sharing one piece of state
screen text after +1, +1, -1: Count: 1
✓ src/use-state/practice/CounterWithDecrement.test.tsx > adds and subtracts with two buttons sharing one piece of state 200ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:39:22
Duration 41.09s (environment 68%, setup 21%, import 9%, transform 1%, tests 1%)
Two +1 clicks and one -1 land on Count: 1, visible directly in the logged screen text, not just implied by the assertion passing.
2. Compare mutating a state object directly against replacing it with a spread, clicking a “visit” button twice each time.
Solution
// src/use-state/practice/ProfileMutate.tsx
import { useState } from "react";
export function ProfileMutate() {
const [profile, setProfile] = useState({ name: "Ada", visits: 0 });
function recordVisitMutate() {
profile.visits = profile.visits + 1;
setProfile(profile);
}
return (
<div>
<p>{profile.name}: {profile.visits} visits</p>
<button onClick={recordVisitMutate}>Visit (mutate)</button>
</div>
);
}
// src/use-state/practice/ProfileMutate.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProfileMutate } from "./ProfileMutate.js";
it("mutating state and setting the same reference does not update the screen", async () => {
const user = userEvent.setup();
render(<ProfileMutate />);
await user.click(screen.getByRole("button", { name: "Visit (mutate)" }));
await user.click(screen.getByRole("button", { name: "Visit (mutate)" }));
console.log("screen text after two clicks:", screen.getByRole("paragraph").textContent);
expect(screen.getByText("Ada: 0 visits")).toBeInTheDocument();
});
$ npx vitest run use-state/practice/ProfileMutate.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/use-state/practice/ProfileMutate.test.tsx > mutating state and setting the same reference does not update the screen
screen text after two clicks: Ada: 0 visits
✓ src/use-state/practice/ProfileMutate.test.tsx > mutating state and setting the same reference does not update the screen 136ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:40:08
Duration 37.73s (environment 70%, setup 20%, import 9%, transform 1%)
The logged screen text still reads Ada: 0 visits after two clicks — profile was mutated in place, so setProfile(profile) passed the exact same object reference React already had, and React skips the re-render.
// src/use-state/practice/ProfileSpread.tsx
import { useState } from "react";
export function ProfileSpread() {
const [profile, setProfile] = useState({ name: "Ada", visits: 0 });
function recordVisit() {
setProfile({ ...profile, visits: profile.visits + 1 });
}
return (
<div>
<p>{profile.name}: {profile.visits} visits</p>
<button onClick={recordVisit}>Visit</button>
</div>
);
}
// src/use-state/practice/ProfileSpread.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProfileSpread } from "./ProfileSpread.js";
it("a new object reference re-renders correctly", async () => {
const user = userEvent.setup();
render(<ProfileSpread />);
await user.click(screen.getByRole("button", { name: "Visit" }));
await user.click(screen.getByRole("button", { name: "Visit" }));
console.log("screen text after two clicks:", screen.getByRole("paragraph").textContent);
expect(screen.getByText("Ada: 2 visits")).toBeInTheDocument();
});
$ npx vitest run use-state/practice/ProfileSpread.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/use-state/practice/ProfileSpread.test.tsx > a new object reference re-renders correctly
screen text after two clicks: Ada: 2 visits
✓ src/use-state/practice/ProfileSpread.test.tsx > a new object reference re-renders correctly 137ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:40:50
Duration 34.93s (environment 69%, setup 21%, import 9%, transform 1%)
{ ...profile, visits: ... } is a new object every call, so React always sees a different reference and re-renders — the logged screen text now reads Ada: 2 visits after the same two clicks.
3. Click Counter’s +2 (stale) button three times and predict the final count before running it.
Solution
// src/use-state/practice/CounterStaleThrice.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "../Counter.js";
it("clicking the stale +2 button three times only reaches 3, not 6", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "+2 (stale)" }));
await user.click(screen.getByRole("button", { name: "+2 (stale)" }));
await user.click(screen.getByRole("button", { name: "+2 (stale)" }));
expect(screen.getByText("Count: 3")).toBeInTheDocument();
});
$ npx vitest run use-state/practice/CounterStaleThrice.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/use-state/practice/CounterStaleThrice.test.tsx > clicking the stale +2 button three times only reaches 3, not 6 148ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:41:29
Duration 34.70s (environment 69%, setup 20%, import 9%, transform 1%)
Each click is a separate render with its own stale count, so each one adds exactly 1 — three clicks land on 3, not 6.