React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

asyncwaitForfindByloadingerrorsuspensetimersmsw

Testing Async Behavior

Test loading states, error handling, data fetching, Suspense boundaries, and timed operations.

Recipe

Quick-reference recipe card -- copy-paste ready.

import { render, screen, waitFor } from "@testing-library/react";
 
// Wait for an element to appear
const heading = await screen.findByText(/welcome/i); // waits up to 1s
 
// Wait for a condition
await waitFor(() => {
  expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
 
// Wait for an element to disappear
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
 
// Custom timeout
const result = await screen.findByText(/data/i, {}, { timeout: 3000 });
 
// Fake timers for setTimeout/setInterval
vi.useFakeTimers();
render(<Countdown seconds={10} />);
act(() => vi.advanceTimersByTime(5000));
expect(screen.getByText("5 seconds left")).toBeInTheDocument();
vi.useRealTimers();

When to reach for this: When testing any component that fetches data, shows loading spinners, handles errors, or uses timers.

Working Example

// src/components/data-table.tsx
"use client";
 
import { useEffect, useState } from "react";
 
interface Product {
  id: number;
  name: string;
  price: number;
}
 
export function DataTable() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    async function loadProducts() {
      try {
        const res = await fetch("/api/products");
        if (!res.ok) throw new Error("Failed to load products");
        const data: Product[] = await res.json();
        setProducts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        setLoading(false);
      }
    }
    loadProducts();
  }, []);
 
  if (loading) {
    return (
      <div role="status" aria-label="Loading">
        <p>Loading products...</p>
      </div>
    );
  }
 
  if (error) {
    return <p role="alert">Error: {error}</p>;
  }
 
  if (products.length === 0) {
    return <p>No products found.</p>;
  }
 
  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>
        {products.map((product) => (
          <tr key={product.id}>
            <td>{product.name}</td>
            <td>${product.price.toFixed(2)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
// src/components/data-table.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { DataTable } from "./data-table";
 
const products = [
  { id: 1, name: "Widget", price: 9.99 },
  { id: 2, name: "Gadget", price: 24.5 },
  { id: 3, name: "Doohickey", price: 4.0 },
];
 
const server = setupServer(
  http.get("/api/products", () => {
    return HttpResponse.json(products);
  })
);
 
beforeEach(() => server.listen());
afterEach(() => {
  server.resetHandlers();
  server.close();
});
 
describe("DataTable", () => {
  it("shows loading state initially", () => {
    render(<DataTable />);
    expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
  });
 
  it("renders products on success", async () => {
    render(<DataTable />);
 
    // Wait for loading to finish
    await waitFor(() => {
      expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
    });
 
    // Verify table content
    expect(screen.getByText("Widget")).toBeInTheDocument();
    expect(screen.getByText("$9.99")).toBeInTheDocument();
    expect(screen.getByText("Gadget")).toBeInTheDocument();
    expect(screen.getByText("$24.50")).toBeInTheDocument();
 
    // Verify row count
    const rows = screen.getAllByRole("row");
    expect(rows).toHaveLength(4); // 1 header + 3 data rows
  });
 
  it("shows error message on failure", async () => {
    server.use(
      http.get("/api/products", () => {
        return HttpResponse.json(null, { status: 500 });
      })
    );
 
    render(<DataTable />);
 
    const alert = await screen.findByRole("alert");
    expect(alert).toHaveTextContent("Failed to load products");
  });
 
  it("shows empty state when no products", async () => {
    server.use(
      http.get("/api/products", () => {
        return HttpResponse.json([]);
      })
    );
 
    render(<DataTable />);
 
    expect(await screen.findByText(/no products found/i)).toBeInTheDocument();
  });
 
  it("shows loading then transitions to data", async () => {
    render(<DataTable />);
 
    // Loading is visible
    expect(screen.getByText(/loading products/i)).toBeInTheDocument();
 
    // Data appears and loading disappears
    await screen.findByText("Widget");
    expect(screen.queryByText(/loading products/i)).not.toBeInTheDocument();
  });
});

What this demonstrates:

  • MSW server setup for realistic API mocking
  • server.use() for per-test handler overrides
  • Testing loading, success, error, and empty states
  • findByRole and waitFor for async transitions
  • Verifying loading state disappears when data arrives

Deep Dive

How It Works

  • findBy queries are a combination of getBy + waitFor -- they retry until the element appears or timeout
  • waitFor polls its callback at intervals (default 50ms) until it passes or the timeout expires (default 1000ms)
  • MSW intercepts fetch calls at the network level -- your component code makes real fetch calls that MSW catches
  • server.use() adds request handlers that take priority over the default ones -- call server.resetHandlers() in afterEach to remove overrides
  • Testing Library auto-wraps findBy in act() so React state updates are flushed

Variations

Testing Suspense boundaries:

// src/components/user-data.tsx
import { Suspense } from "react";
 
async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
 
// This component suspends while data loads
function UserName({ userPromise }: { userPromise: Promise<{ name: string }> }) {
  const user = use(userPromise);
  return <span>{user.name}</span>;
}
 
export function UserCard({ userId }: { userId: number }) {
  const userPromise = fetchUser(userId);
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserName userPromise={userPromise} />
    </Suspense>
  );
}
it("shows fallback then user name", async () => {
  server.use(
    http.get("/api/users/1", async () => {
      await delay(100); // simulate network latency
      return HttpResponse.json({ name: "Alice" });
    })
  );
 
  render(<UserCard userId={1} />);
  expect(screen.getByText("Loading user...")).toBeInTheDocument();
  expect(await screen.findByText("Alice")).toBeInTheDocument();
});

Fake timers for setTimeout/setInterval:

// src/components/auto-save.tsx
"use client";
 
import { useEffect, useState } from "react";
 
export function AutoSave({ onSave }: { onSave: () => Promise<void> }) {
  const [status, setStatus] = useState("idle");
 
  useEffect(() => {
    const interval = setInterval(async () => {
      setStatus("saving");
      await onSave();
      setStatus("saved");
    }, 30000);
    return () => clearInterval(interval);
  }, [onSave]);
 
  return <p>{status === "saving" ? "Saving..." : status === "saved" ? "Saved" : ""}</p>;
}
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
 
describe("AutoSave", () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());
 
  it("saves every 30 seconds", async () => {
    const onSave = vi.fn().mockResolvedValue(undefined);
    render(<AutoSave onSave={onSave} />);
 
    // Advance past first interval
    await act(async () => {
      vi.advanceTimersByTime(30000);
    });
 
    expect(onSave).toHaveBeenCalledTimes(1);
 
    // Advance another interval
    await act(async () => {
      vi.advanceTimersByTime(30000);
    });
 
    expect(onSave).toHaveBeenCalledTimes(2);
  });
});

TypeScript Notes

// Type the MSW handler response for safety
import { http, HttpResponse } from "msw";
 
interface Product {
  id: number;
  name: string;
  price: number;
}
 
http.get("/api/products", () => {
  return HttpResponse.json<Product[]>([
    { id: 1, name: "Widget", price: 9.99 },
  ]);
});

Gotchas

  • waitFor with side effects -- The callback in waitFor runs multiple times. Do not put userEvent clicks or other side effects inside it. Fix: Only put assertions inside waitFor.

  • Fake timers breaking waitFor -- vi.useFakeTimers() can freeze waitFor's polling interval. Fix: Either advance timers inside act() before waitFor, or use vi.useFakeTimers({ shouldAdvanceTime: true }).

  • Not cleaning up MSW server -- Forgetting server.resetHandlers() causes handler overrides to leak between tests. Fix: Always call server.resetHandlers() in afterEach.

  • Testing async state updates without waiting -- Synchronous assertions after render() see the initial state, not the resolved state. Fix: Use findBy or waitFor for all async assertions.

  • Multiple waitFor blocks that could be one -- Each waitFor re-polls independently, adding unnecessary test time. Fix: Combine related assertions in a single waitFor:

    // Slow: two separate waitFor calls
    await waitFor(() => expect(a).toBeInTheDocument());
    await waitFor(() => expect(b).toBeInTheDocument());
     
    // Fast: one waitFor with both assertions
    await waitFor(() => {
      expect(a).toBeInTheDocument();
      expect(b).toBeInTheDocument();
    });

Alternatives

AlternativeUse WhenDon't Use When
MSWYou want realistic network-level mocking that works with any fetch libraryYou need to mock non-HTTP async operations
vi.stubGlobal("fetch")Quick one-off fetch mocking without MSW setupYou have many API endpoints to mock (MSW is more maintainable)
PlaywrightYou need to test async UI flows in a real browserFast unit-level async tests are sufficient
React Suspense testing utilitiesYou test server-side streaming or RSCYour components use useEffect-based fetching

FAQs

What is the difference between findBy and waitFor?
  • findBy is shorthand for getBy + waitFor -- it waits for an element to appear in the DOM.
  • waitFor retries any assertion callback until it passes or times out.
  • Use findBy when waiting for an element; use waitFor for non-element assertions.
How do I wait for a loading spinner to disappear?
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
Gotcha: Why should I never put side effects inside waitFor?

The callback in waitFor runs multiple times (polling). Putting userEvent.click() or other side effects inside it would trigger those actions repeatedly. Only put assertions inside waitFor.

How do I use MSW to mock API responses in tests?
  • Define handlers with http.get(), http.post(), etc.
  • Create a server with setupServer(...handlers).
  • Use server.use() in individual tests to override responses.
  • Call server.resetHandlers() in afterEach.
How do I test a component that uses setTimeout or setInterval?
vi.useFakeTimers();
render(<AutoSave onSave={onSave} />);
await act(async () => {
  vi.advanceTimersByTime(30000);
});
expect(onSave).toHaveBeenCalledTimes(1);
vi.useRealTimers();
Gotcha: Why does vi.useFakeTimers() freeze waitFor?

Fake timers prevent waitFor's polling interval from advancing. Fix by either advancing timers inside act() before using waitFor, or use vi.useFakeTimers({ shouldAdvanceTime: true }).

How do I test a Suspense boundary with a loading fallback?

Render the component and assert the fallback first, then wait for the resolved content:

render(<UserCard userId={1} />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
How do I combine multiple async assertions efficiently?

Use a single waitFor with all assertions instead of multiple separate waitFor calls:

await waitFor(() => {
  expect(a).toBeInTheDocument();
  expect(b).toBeInTheDocument();
});
How do I type MSW handler responses in TypeScript?
http.get("/api/products", () => {
  return HttpResponse.json<Product[]>([
    { id: 1, name: "Widget", price: 9.99 },
  ]);
});
What is the default timeout for findBy and waitFor?

Both default to 1000ms. You can customize it:

await screen.findByText(/data/i, {}, { timeout: 3000 });
Why should I call server.resetHandlers() in afterEach?

Without it, handler overrides added via server.use() leak between tests, causing unexpected behavior in subsequent tests.

How do I test the loading-to-data transition?

Assert loading is visible immediately after render, then use findBy to wait for data and assert loading is gone:

render(<DataTable />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await screen.findByText("Widget");
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();