, ,

Master async React tests

Mast async React tests

Modern React apps lean heavily on local state and async side effects (fetching data, debounced handlers, timers, transitions). If your tests don’t model those behaviours accurately, you’ll ship brittle UI and flaky tests.

This post tackles three common pain points and shows reliable test patterns with React Testing Library (RTL) + Jest/Vitest.


Common issues developers face

  1. State updates not reflecting immediately
    React batches updates and applies them after the current event tick. In tests, that can look like “my assertion ran before React updated the DOM.”
  2. Testing async side effects (fetch, timers, debounced inputs)
    You must wait for the side effect to finish or control the clock otherwise you’ll assert too early.
  3. Misunderstanding the rendering lifecycle
    Effects run after paint, React 18 auto-batches updates, and state updates triggered by promises/timers need you to await or advance timers.

What you’ll learn

  • A quick mental model for React state and effects in tests
  • When to use findBy*, waitFor, and act
  • How to test timers, debounces, and fetches without flakiness
  • Patterns for mocking network calls (or using MSW)
  • Small checklists to keep tests fast and stable

A quick mental model

Events → state → render → commit → effects

What each step means

  • Event
    A user (or program) triggers something: a click, input, timer, promise resolution, etc. Your handler runs synchronously.
  • State
    Inside that handler (or callback), you call setState. This schedules an update. In React 18, updates from the same tick are auto-batched.
  • Render (reconcilliation)
    React computes the next UI purely from props/state. No DOM changes yet. In concurrent mode this can be started, paused, or thrown away and restarted. Side effects must not happen here.
  • Commit
    React applies the changes: DOM mutations, refs, layout. This is when the screen actually updates. If you need to read layout synchronously after DOM writes, that’s what useLayoutEffect is for (it runs after commit, before paint).
  • Effects
    “Passive” effects (useEffect) run after paint. Network requests, logging, subscriptions. Anything that shouldn’t block painting goes here.

Event handlers run synchronously, but React batches setState and commits after the handler.

useEffect runs after commit if state changes in an effect or a promise, your test must await the resulting DOM change.

In RTL:

  • Prefer findby* for async UI (it includes waiting)
  • Use waitFor when you need to wait for arbitrary conditions
  • Use fake timers to control setTimeout/Debounce
  • Use MSW or lightweight mocks for network calls

Pattern 1 – Synchronous state updates (no waiting needed)

Component

function Toggle() {
  const [on, setOn] = React.useState(false);
  const handleToggle = React.useCallback(() => setOn(v => !v), []);
  return (
    <button onClick={handleToggle}>
      {on ? "On" : "Off"}
    </button>
  );
}

Test

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

test("toggles immediately on click", async () => {
  const user = userEvent.setup()
  const { getByRole } = render(<Toggle />);
  const btn = getByRole("button", { name: /off/i });
  await user.click(btn); // userEvent already wraps updates properly
  expect(btn).toHaveTextContent(/on/i); // no wait needed
});

Signal: If the update is triggered directly in the event handler, you usually don’t need waitFor.


Pattern 2 – Async updates via promises (await the UI, don’t guess timing)

Component

type Props = { save: () => Promise<void> };

function SaveButton({ save }: Props) {
  const [status, setStatus] = React.useState<"idle"|"saving"|"saved"|"error">("idle");
  const onClick = async () => {
    setStatus("saving");
    try {
      await save();
      setStatus("saved");
    } catch {
      setStatus("error");
    }
  };
  return (
    <div>
      <button onClick={onClick} disabled={status === "saving"}>Save</button>
      <span aria-live="polite">{status}</span>
    </div>
  );
}

Test (mock the promise, await the DOM)

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

test("shows saved after successful save", async () => {
  const user = userEvent.setup();
  const save = vi.fn().mockResolvedValue(undefined); // or jest.fn()
  const { getByRole, findByText } = render(<SaveButton save={save} />);

  await user.click(getByRole("button", { name: /save/i }));

  // Don't wait on the promise directly; wait on the UI:
  expect(await findByText(/saved/i)).toBeInTheDocument();
});

Why this works: findByText polls until the text appears or times out. You avoid flaky sleeps and align the test with user-visible results.


Pattern 3 – Timers and debounced updates (control the clock)

Component (debounced search)

import React from "react";

type SearchProps = {
  fetchResults: (q: string) => Promise<string[]>;
};

export function Search({ fetchResults }: SearchProps) {
  const [q, setQ] = React.useState<string>("");
  const [results, setResults] = React.useState<string[]>([]);

  const handleChange = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setQ(e.target.value);
    }, []
  );

  React.useEffect(() => {
    if (!q) {
      setResults([]);
      return;
    }

    const id = setTimeout(async () => {
      const r = await fetchResults(q);
      setResults(r);
    }, 300);
    return () => clearTimeout(id);
  }, [q, fetchResults]);

  return (
    <>
      <label htmlFor="query-input">Query</label>
      <input
        id="query-input"
        aria-label="query"
        value={q}
        onChange={handleChange}
      />
      <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

Test (fake timers and user typing)

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

beforeEach(() => {
  vi.useFakeTimers(); // or jest.useFakeTimers()
});
afterEach(() => {
  vi.useRealTimers();
});

test("debounces and shows results", async () => {
  const user = userEvent.setup();
  const fetchResults = vi.fn().mockResolvedValue(["alpha", "beta"]);
  const { queryByText, findByText } = render(<Search fetchResults={fetchResults} />);

  await user.type(screen.getByLabelText(/query/i), "a");
  // Nothing yet because of debounce:
  expect(queryByText(/alpha/i)).not.toBeInTheDocument();

  // Advance time to trigger the effect:
  vi.advanceTimersByTime(300);

  // Await the UI change after the promise resolves
  expect(await findByText(/alpha/i)).toBeInTheDocument();
  expect(fetchResults).toHaveBeenCalledWith("a");
});

Tips

  • Always advance timers to the exact debounce delay.
  • After advancing, still await the UI (findBy*) because a promise resolves after the timer callback.

Pattern 4 – Fetch in useEffect (mock or use MSW)

Component (basic fetch)

function UserList() {
  const [users, setUsers] = React.useState<string[] | null>(null);
  const [error, setError] = React.useState<string | null>(null);

  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch("/api/users");
        if (!res.ok) throw new Error("HTTP " + res.status);
        const data: string[] = await res.json();
        if (!cancelled) setUsers(data);
      } catch (e: any) {
        if (!cancelled) setError(e?.message ?? "error");
      }
    })();
    return () => { cancelled = true; };
  }, []);

  if (error) return <p role="alert">Failed: {error}</p>;
  if (!users) return <p>Loading…</p>;
  return <ul>{users.map(u => <li key={u}>{u}</li>)}</ul>;
}

Test (MSW recommended)

// With MSW, define a handler for GET /api/users returning JSON.
// In test:
import { render} from "@testing-library/react";

test("renders users from API", async () => {
  const { getByText, findByRole, getAllByRole } = render(<UserList />);
  expect(getByText(/loading/i)).toBeInTheDocument();

  // MSW serves the response; await the UI:
  expect(await findByRole("list")).toBeInTheDocument();
  expect(screen.getAllByRole("listitem")).toHaveLength(3);
});

Withour MSW (quick mock)

beforeEach(() => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ["Ada", "Linus", "Grace"],
  } as any);
});

test("renders users from API", async () => {
  const { findByText } = render(<UserList />);
  expect(await findByText(/Ada/)).toBeInTheDocument();
});

Pattern 5 – Updating from props/external events (use rerender and findBy*)

import { render} from "@testing-library/react";

function Status({ ready }: { ready: boolean }) {
  const [text, setText] = React.useState("Booting…");
  React.useEffect(() => {
    if (ready) setText("Ready");
  }, [ready]);
  return <div>{text}</div>;
}

test("updates when prop changes", async () => {
  const {
    rerender,
    getByText,
    findByText,
  } = render(<Status ready={false} />);
  expect(getByText(/booting/i)).toBeInTheDocument();

  rerender(<Status ready={true} />);

  // Effect runs after commit → await the new text
  expect(await findByText(/ready/i)).toBeInTheDocument();
});

When to use what

  • Direct event → immediate DOM change: getBy* and userEvent (no waiting).
  • Async side effects (fetch, promise, effect): findBy* for the target element.
  • Complex conditions: waitFor(() => expect(...)).
  • Timers/debounces: fake timers and advanceTimersByTime, then findBy*.
  • Transitions or Suspense: still await the user-visible state (findBy* or waitForElementToBeRemoved for spinners).

Common anti-patterns (and fixes)

await new Promise(r => setTimeout(r, X)) in tests
Wait for UI with findBy* or advance timers deterministically.
Asserting internal state (implementation detail)
Assert what the user sees (text, buttons enabled/disabled, aria-live, etc.).
Forcing act everywhere
RTL’s helpers already wrap updates. Reach for act explicitly only when you manually tick timers or resolve promises in unusual ways.
Mocking too low (e.g., stubbing useEffect or setState)
Mock I/O boundaries (network) or time, not React internals.

Minimal checklist for stable async tests

  • Prefer findBy* for async UI, getBy* for sync.
  • Use fake timers for debounces/throttles and advance them.
  • Mock network at the boundary (MSW or global.fetch)
  • Wait for removals with waitForElementToBeRemoved (eg Spinners)
  • Clean up timers/mocks in afterEach
  • Keep assertions focused on the user-visible outcomes.

Bonus – Testing hooks directly


Test state in hook

import { renderHook, act } from "@testing-library/react";

function useCounter() {
  const [n, setN] = React.useState(0);
  const inc = () => setN(v => v + 1);
  const incAsync = () => setTimeout(inc, 200);
  return { n, inc, incAsync };
}

test("hook: async increment", () => {
  vi.useFakeTimers();
  const { result } = renderHook(() => useCounter());
  act(() => result.current.incAsync());
  vi.advanceTimersByTime(200);
  expect(result.current.n).toBe(1);
  vi.useRealTimers();
});

Wrap up

Testing state and async side-effects in React becomes straightforward once you

  • Think in terms of what the user sees, not internal state.
  • Await the DOM, not arbitrary time
  • Control time and network at the edges.

Resources

Jest / Vitest / RTL / MSW


Looking for developers that build React test scripts? Reach out

Back to Insights