Testing Hooks & State

Course 1 · Ch 5
Testing Hooks & State
Testing logic with no rendered output of its own, and components whose behavior depends on timing

Chapter 4 tested components as black boxes through their rendered output. This chapter covers two more specific situations: testing a custom hook in isolation — which has no JSX of its own at all — and testing components whose correctness depends on useState/useEffect timing, including controlled forms and asynchronous updates.

Testing Custom Hooks with renderHook

A custom hook like useCounter isn't a component — render() and screen don't apply. renderHook mounts a hook inside a minimal, invisible test component behind the scenes, returning a result object whose .current holds the hook's live return value:

import { renderHook, act } from "@testing-library/react"; it("increments the count", () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); });

act(...) tells React "a state update just happened here — flush it and re-render before continuing." Without it, an assertion immediately after calling an updater function can read a stale value from before React has actually processed the update.

Testing Components That Use useState

A controlled input's displayed value depends on component state that changes as the user types — combining Chapter 4's userEvent.type with a query that reads the input's current value:

it("updates the input as the user types", async () => { render(<SearchBox />); const input = screen.getByLabelText("Search"); await userEvent.type(input, "react testing"); expect(input).toHaveValue("react testing"); });

Testing useEffect Timing

A component that fetches data in useEffect doesn't show its result synchronously — this is exactly where Chapter 3's findBy and the more general waitFor matter. findBy* is a convenience wrapper built around one query; waitFor(callback) is general-purpose — it re-runs an arbitrary assertion callback until it passes or times out, useful when a check can't be expressed as a single query.

// Simple case — one query eventually succeeds expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); // More complex — two conditions checked together, not expressible as one query await waitFor(() => { expect(fetchUser).toHaveBeenCalledTimes(1); expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); });

Controlled Forms End to End

Combining everything: typing into multiple controlled inputs, submitting, and confirming an async result:

it("submits the form and shows a success message", async () => { render(<ProfileForm />); await userEvent.type(screen.getByLabelText("Name"), "Ada"); await userEvent.type(screen.getByLabelText("Email"), "ada@example.com"); await userEvent.click(screen.getByRole("button", { name: "Save" })); expect(await screen.findByText("Profile saved!")).toBeInTheDocument(); });

renderHook

Mounts a custom hook with no JSX of its own; result.current holds its live return value.

act()

Flushes a state update before assertions continue — required when calling an updater outside RTL's own built-in helpers.

findBy* (simple)

A single query, built-in retry — the right choice when one thing needs to eventually appear.

waitFor (flexible)

Wraps an arbitrary assertion callback — the right choice when multiple conditions need checking together.

Modern RTL Auto-Wraps Most Things in act()
Older tutorials show explicit act(...) calls everywhere — but render, fireEvent, and userEvent now wrap themselves in act internally. Explicit act() is mainly needed for renderHook's own updater calls (as above) or when triggering a state change through something entirely outside RTL's built-in helpers.
Forgetting await on findBy/waitFor Produces Flaky, Not Broken, Tests
Both findBy* and waitFor return Promises. Forgetting await doesn't throw a clear error — it lets the test's assertions run against the wrong moment in time, before the update has actually finished. The result is a test that sometimes passes and sometimes fails depending on timing — a genuinely nasty class of bug to diagnose, and the exact same category of mistake as Chapter 4's forgotten-await-on-userEvent gotcha, just showing up again here.

Coding Challenges

Challenge 1

Write a renderHook test for a custom hook useToggle() that returns { value, toggle }, starting at false, confirming that calling toggle() once sets value to true and calling it again sets it back to false.

📄 View solution
Challenge 2

Write a test for a component that fetches a list of items on mount (in useEffect) and shows "Loading..." until they arrive. Use findByText to confirm an item eventually appears, and queryByText to confirm "Loading..." is gone by then.

📄 View solution
Challenge 3

Explain why forgetting await on a findByText call can make a test pass sometimes and fail other times, rather than failing consistently and obviously — referencing what the test actually does if the Promise is never awaited.

📄 View solution

Chapter 5 Quick Reference

  • renderHook(() => useX()) — mounts a custom hook; result.current holds its live value
  • act(() => { ... }) — flushes a state update; needed for renderHook updater calls, largely automatic elsewhere now
  • toHaveValue(...) — asserts a controlled input's current value
  • findBy* — single query, built-in retry, for one thing appearing eventually
  • waitFor(callback) — general-purpose retry for arbitrary/multiple assertions together
  • Modern render/fireEvent/userEvent auto-wrap in act() — explicit act() is the exception now, not the rule
  • Forgetting await on findBy/waitFor produces flaky tests, not clear errors — the same risk class as Chapter 4's userEvent gotcha
  • Next chapter: Mocking Dependencies — mocking fetch/axios, Mock Service Worker (MSW), timers, and context providers