Skip to main content
React · lesson 4 of 22
React beginner Lesson 4 of 22

Also 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

react hooks useState vitest

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.

Frequently Asked Questions

Why does clicking a button twice in one handler only add 1, not 2?
Each setCount(count + 1) call closes over the count value from the render that created the handler, which doesn't change mid-handler. Calling it twice with the same stale count just schedules the same new value twice. Pass a function, setCount(c => c + 1), and each call receives the latest pending value instead.
Why use Testing Library and user-event instead of calling the component function directly?
A hook like useState only works inside a real render managed by React, so the component has to be mounted into a DOM — here, jsdom, via Testing Library's render. user-event then simulates a real user action (a click, typing) rather than calling the onClick handler directly, which exercises the same event path a browser would use.
Does calling setState always trigger a re-render?
No. React skips the re-render if the new value is the exact same value (by Object.is) as the current one — true for primitives that happen not to change, and for an object or array reference that wasn't actually replaced, even if its contents were mutated in place.
What does render count prove that the final DOM text doesn't?
The final text only shows the end result. Counting renders (or, as here, counting commits caused by a click) shows the mechanism underneath — whether React batched multiple state updates from one handler into a single render, which matters for performance and for effects that run once per render.