Testing Loading, Success, Error, and Race-Condition States

Write reliable tests for every state an async UI can be in — loading, success, error, empty, retry, race conditions, cancellation, optimistic updates, and cached server state — using React Testing Library's async utilities instead of brittle timers or manual waits.

A component that fetches data does not have one behavior — it has a small state machine: idle, loading, success, error, and often empty and retry states layered on top. Most bugs in production async UIs live in the transitions between these states, not in the happy path a developer eyeballs once in the browser. Testing async UI means asserting on the DOM as it changes over time, using React Testing Library's findBy* queries and waitFor to let React's scheduler and your mocked network resolve naturally, rather than reaching for setTimeout guesses or manually flushing promises. This lesson builds a full test suite for a UserProfile component that covers every state a real async component can be in, including the two hardest cases: a request that resolves out of order (a race condition) and a request that outlives the component that started it (a needed cancellation).

The async state machine your component actually has

Any component built around fetch or a data hook implicitly cycles through a set of states, whether or not you modeled them explicitly with useState or a state machine library. React Testing Library encourages you to test the *rendered output* for each state rather than the internal state variable, because rendered output is what the user (and the test) can actually observe.

Treat each state as a first-class thing to test in isolation: what does the DOM look like the instant the component mounts and the request has not resolved yet (loading)? What does it look like once data arrives (success)? What if the array of results is empty (empty, which is distinct from error and often forgotten)? What if the request rejects (error)? And what happens when the user retries after an error?

• Loading — a spinner, skeleton, or aria-busy region is present before data arrives. • Success — the fetched data is rendered; loading indicators are gone. • Error — a user-facing error message is shown, not a blank screen or console-only error. • Empty — the request succeeded but returned zero items; this needs its own message, distinct from error. • Retry — after an error, a retry action re-triggers the request and can succeed.

Querying for what appears later: findBy vs getBy vs queryBy

React Testing Library's three query families exist specifically for async assertions. getBy* throws immediately if nothing matches, which is correct for synchronous assertions but wrong for anything that appears after a promise resolves — it will fail before the promise has a chance to settle. queryBy* returns null instead of throwing, which is what you want when asserting something is *absent* (for example, confirming a spinner is gone). findBy* is getBy* combined with waitFor: it retries the query on every DOM mutation until it finds a match or times out (1000ms by default), and it is what you use for anything that shows up after an async operation.

The rule of thumb: assert the loading state with a synchronous getBy immediately after render, assert the eventual state with findBy, and assert something has disappeared with waitForElementToBeRemoved or queryBy inside waitFor.

Race conditions and cancellation are testable, not just theoretical

A race condition happens when two async operations are in flight and the one that started second resolves first, or the one that started first resolves after the component has moved on to different props. The classic bug: a search box fires a request per keystroke, and the response for an early keystroke arrives after — and overwrites — the response for the latest keystroke. You test this by controlling the order in which mocked promises resolve, not by hoping timing works out.

Cancellation is the fix for that bug, usually via AbortController or an 'ignore this result' flag tied to a useEffect cleanup. Testing cancellation means proving that a request whose owning component unmounted (or whose inputs changed) does not update the DOM after the fact — and, just as important, that it does not throw an unhandled 'setState on unmounted component' warning.

Optimistic updates and cached server state (the domain of libraries like TanStack Query or SWR) add a further layer: you must test that the UI updates immediately on user action (before the server confirms), and rolls back correctly if the server rejects — plus that a second render of the same query reads from cache instantly without a new loading flash.

Core async testing utilities

findBy* has a built-in waitFor with a 1000ms default timeout — override per-call with { timeout: 3000 } as a second argument. Never wrap a findBy* call in an extra act(); Testing Library already batches its internal updates correctly. waitFor's callback must be side-effect-safe: it can run many times, so don't mutate shared state inside it.

import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

// Wait for something to APPEAR (returns a promise you must await)
const successEl = await screen.findByText(/loaded/i);

// Wait for something to DISAPPEAR
await waitForElementToBeRemoved(() => screen.queryByRole("status"));

// Wait for an arbitrary assertion / side effect to become true
await waitFor(() => {
  expect(mockFetch).toHaveBeenCalledTimes(1);
});

// Assert something is absent RIGHT NOW (no async component in flight)
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();

Code Examples

Each test isolates one state by controlling exactly what the mocked fetchUser does: never resolving for loading, resolving with data for success, resolving with an empty array for empty, and rejecting then resolving for the error-then-retry flow. Using findBy* means the test naturally waits for React's state updates instead of asserting too early. Expected result: All four tests pass; each asserts a distinct, mutually exclusive DOM state.

expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();

The mock intentionally makes the earlier query ('re') resolve slower than the later one ('react'), reproducing a real race condition. The AbortController cleanup in the effect cancels the stale request when the query changes, and the test proves the stale result never reaches the DOM — this is the behavior that actually matters, not the implementation detail of how cancellation is wired. Without the AbortController cleanup, this test fails intermittently or deterministically shows 're-stale' overwriting 'react-fresh' — exactly the bug users see as 'search results flicker to the wrong thing'.

Holding the API promise open with a manually-captured reject function lets the test observe the optimistic state (button already says 'Liked') before ever resolving the network call, then trigger and assert the rollback separately. This is the only reliable way to test the in-between optimistic state — resolving/rejecting synchronously in the mock would collapse both states into one render.

rejectApi(new Error("500"));

Common mistake: Using mockRejectedValue instead of a manually-controlled promise makes it impossible to assert the optimistic state exists before the rollback, since the mock rejects before the test can inspect the intermediate render.

// UserProfile.tsx
import { useEffect, useState } from "react";

type User = { id: string; name: string };

export function UserProfile({ userId, fetchUser }: {
  userId: string;
  fetchUser: (id: string) => Promise<User[]>;
}) {
  const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
  const [users, setUsers] = useState<User[]>([]);
  const [error, setError] = useState<string | null>(null);

  const load = () => {
    setStatus("loading");
    setError(null);
    fetchUser(userId)
      .then((data) => {
        setUsers(data);
        setStatus("success");
      })
      .catch((err: Error) => {
        setError(err.message);
        setStatus("error");
      });
  };

  useEffect(load, [userId]);

  if (status === "loading") return <div role="status">Loading…</div>;
  if (status === "error") {
    return (
      <div>
        <p role="alert">{error}</p>
        <button onClick={load}>Retry</button>
      </div>
    );
  }
  if (users.length === 0) return <p>No users found.</p>;
  return (
    <ul>
      {users.map((u) => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

// UserProfile.test.tsx
import { render, screen } from "@testing-library/react";
im
…
// SearchBox.tsx — buggy version has no guard, fixed version does
import { useEffect, useState } from "react";

export function SearchBox({ search }: { search: (q: string, signal: AbortSignal) => Promise<string[]> }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (!query) { setResults([]); return; }
    const controller = new AbortController();
    search(query, controller.signal)
      .then(setResults)
      .catch((err) => {
        if (err.name !== "AbortError") throw err;
      });
    return () => controller.abort(); // cancels the in-flight request on re-run/unmount
  }, [query, search]);

  return (
    <div>
      <input
        aria-label="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
    </div>
  );
}

// SearchBox.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect } from "vitest";
import { SearchBox } from "./SearchBox";

it("discards a stale response that resolves out of order", async () =>
…
// LikeButton.tsx
import { useState } from "react";

export function LikeButton({ liked, likeApi }: { liked: boolean; likeApi: () => Promise<void> }) {
  const [optimisticLiked, setOptimisticLiked] = useState(liked);
  const [error, setError] = useState(false);

  const handleClick = async () => {
    setOptimisticLiked(true); // update UI immediately, before the server confirms
    setError(false);
    try {
      await likeApi();
    } catch {
      setOptimisticLiked(false); // roll back on failure
      setError(true);
    }
  };

  return (
    <div>
      <button onClick={handleClick} aria-pressed={optimisticLiked}>
        {optimisticLiked ? "Liked" : "Like"}
      </button>
      {error && <p role="alert">Couldn't save your like. Try again.</p>}
    </div>
  );
}

// LikeButton.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect } from "vitest";
import { LikeButton } from "./LikeButton";

it("updates immediately, then rolls back when the server rejects", async () => {
  let rejectApi!: (e: Error) => void;
  const likeApi = vi.fn(
    () => new Promise<void>((_, reject) => { rejectApi 
…

The async UI states a test suite should cover

Five states plus four cross-cutting concerns — each needs its own test, not just the happy path.

mount
    │
    ▼
 ┌─────────┐   resolves, data.length>0   ┌─────────┐
 │ LOADING │ ───────────────────────────▶│ SUCCESS │
 └─────────┘                             └─────────┘
    │   │  resolves, data.length===0
    │   └──────────────────────────────▶ ┌───────┐
    │                                     │ EMPTY │
    │  rejects                            └───────┘
    ▼
 ┌───────┐   user clicks Retry    (back to LOADING)
 │ ERROR │ ───────────────────────────▶
 └───────┘

 Orthogonal concerns layered on top of any transition above:
   RACE        — a slower earlier request must not overwrite a faster later one
   CANCEL      — unmount/re-run must abort the in-flight request, no late setState
   OPTIMISTIC  — UI updates before the server confirms, then commits or rolls back
   CACHE       — a second mount of the same query reads instantly, no LOADING flash

Important Rules

Assert the loading state synchronously with getBy right after render, before awaiting anything — if you await first, you may miss the loading frame entirely.

Use findBy* (never getBy* immediately) for anything that only appears after a promise resolves.

Treat 'empty success' as its own state distinct from 'error' — write a dedicated test for zero results.

For race conditions, deliberately make an earlier-triggered mock resolve later than a subsequent one; don't rely on incidental timing.

For cancellation, assert both that stale data never reaches the DOM and that no 'setState on unmounted component' warning is logged.

For optimistic updates, hold the underlying promise open manually so you can assert the pre-confirmation UI state before resolving or rejecting it.

For cached server state (TanStack Query/SWR), test that a second render of the same query key shows data instantly with no loading flash, using the library's test utilities or a shared QueryClient across renders.

Why this matters beyond the test suite

Async-state bugs are disproportionately expensive in production because they're intermittent: a race condition only shows up under real network jitter, and a missing cancellation only leaks a warning (or worse, stale UI) when a user navigates away quickly — exactly the behavior QA rarely reproduces on a fast local network. Teams that explicitly test loading/error/empty/race/cancel/optimistic/cache as separate cases catch these before they reach users who are on slow connections, switching tabs mid-load, or double-clicking a Like button. It's also the fastest way to document intended behavior: a new engineer reading 'shows an error and allows retry' as a test name understands the contract without reading the implementation.

render(<UserProfile userId="1" fetchUser={fetchUser} />); expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();

render(<UserProfile userId="1" fetchUser={fetchUser} />); expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();

getBy* throws synchronously if the element isn't there yet; since the fetch promise hasn't resolved on the first tick, this fails even though the data will arrive a microtask later. findBy* retries until it appears.

await new Promise((r) => setTimeout(r, 500)); expect(screen.getByText(/loaded/i)).toBeInTheDocument();

expect(await screen.findByText(/loaded/i)).toBeInTheDocument();

Sleeping a fixed amount of time is slow (always pays the full delay) and flaky (too short on a loaded CI box, or masks a real regression that made the fetch take longer than expected). findBy* resolves as soon as the DOM updates, and fails fast with a clear timeout error otherwise.

it("handles errors", async () => { fetchUser.mockRejectedValue(new Error("fail")); render(<UserProfile userId="1" fetchUser={fetchUser} />); await screen.findByRole("alert"); });

it("shows the error message and lets the user retry", async () => { fetchUser.mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce([...]); render(<UserProfile userId="1" fetchUser={fetchUser} />); expect(await screen.findByRole("alert")).toHaveTextContent("fail"); await user.click(screen.getByRole("button", { name: /retry/i })); expect(await screen.findByText(...)).toBeInTheDocument(); });

Only checking that an alert exists doesn't verify the message is meaningful or that retry actually works — the two things a user (and QA) actually cares about. A test that passes when the error text is wrong, or when the retry button is a dead end, has a false sense of coverage.

Why does screen.getByText(...) immediately after render() fail for data that arrives from a mocked async fetch, even when the mock will eventually resolve successfully? A. getBy* queries are disabled inside async components B. getBy* throws synchronously if no match exists yet, and the fetch promise hasn't resolved on the very next line of a synchronous test body C. React Testing Library requires act() around every getBy* call D. Because Vitest mocks always resolve on a delay longer than one tick

B. getBy* throws synchronously if no match exists yet, and the fetch promise hasn't resolved on the very next line of a synchronous test body — getBy* is synchronous — it checks the DOM as it exists right now and throws if nothing matches. Since the mocked promise resolves on a microtask/macrotask, the DOM hasn't updated by the very next synchronous line, so getBy* fails. findBy* solves this by polling with waitFor.

In the SearchBox race-condition test, why is the mock written so that the 're' query resolves after the 'react' query, even though 're' was requested first? A. To make the test run faster overall B. Vitest requires mocks to resolve in reverse call order C. To deliberately reproduce the real-world race where a slower earlier request could overwrite a faster later one, so the test proves the AbortController cleanup actually prevents that D. Because React always processes the most recent effect's promise first regardless of timing

C. To deliberately reproduce the real-world race where a slower earlier request could overwrite a faster later one, so the test proves the AbortController cleanup actually prevents that — Race conditions only manifest when responses arrive out of request order. Forcing the earlier request to resolve later is what makes the test actually exercise the cancellation logic instead of passing by coincidence.

What does it mean for a component test to correctly verify an 'optimistic update', as opposed to just testing eventual success? A. It only needs to check the final DOM state after the promise resolves B. It must observe the UI change immediately, before the underlying promise resolves, by holding the promise open (e.g. capturing its resolve/reject) and asserting the intermediate state C. It should mock the API to resolve synchronously so there is no intermediate state to worry about D. Optimistic updates cannot be unit tested and require end-to-end tools

B. It must observe the UI change immediately, before the underlying promise resolves, by holding the promise open (e.g. capturing its resolve/reject) and asserting the intermediate state — The defining feature of an optimistic update is that the UI changes before server confirmation. Testing it requires a promise the test controls manually so it can assert the pre-confirmation DOM state, then separately resolve or reject to assert the commit or rollback.

Extend the UserProfile test suite with a test for 'stale data must not appear after unmount': render the component with a fetchUser mock whose promise resolves after the component is unmounted, unmount it via RTL's unmount() before the promise settles, then assert (a) no React act()/setState warning was logged to console.error, and (b) nothing throws. Then add a second test using TanStack Query's QueryClient (or a hand-rolled cache) proving that rendering the same query key a second time shows cached data with zero loading flash.

it('does not warn after unmount before resolve', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let resolveFetch!: (v: any) => void; const fetchUser = vi.fn(() => new Promise((res) => { resolveFetch = res; })); const { unmount } = render(<UserProfile userId="1" fetchUser={fetchUser} />); unmount(); resolveFetch([{ id: '1', name: 'Late' }]); await new Promise((r) => setTimeout(r, 0)); expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('not wrapped in act')); errorSpy.mockRestore(); });

Spy on console.error with vi.spyOn(console, 'error') before the test and assert it was not called after unmount + flush; for the cache test, render twice against the same QueryClient instance and assert getByText for the cached value appears with no intervening getByRole('status').