Components that fetch data move through several states: loading, success, empty, and error. A test cannot assert the final state immediately, because the data has not arrived yet.
This guide shows how to test async React components by waiting for each state instead of racing it. Waiting keeps tests deterministic and free of the arbitrary sleeps that slow a suite down.
A component with every state
A user list that fetches from an API makes all four states visible. The effect starts a request, updates state on success, and records an error on failure.
import { useEffect, useState } from "react";
export default function UserList() {
const [users, setUsers] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
fetch("/api/users")
.then((response) => response.json())
.then((data) => { if (!ignore) setUsers(data); })
.catch(() => { if (!ignore) setError("Could not load users."); });
return () => { ignore = true; };
}, []);The ignore flag and cleanup prevent a stale response from updating the component after it unmounts. The render branches below follow the effect in the same file. Splitting the example this way keeps each snippet small enough to read in one glance.
if (error) {
return <p role="alert">{error}</p>;
}
if (users === null) {
return <p>Loading users</p>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Each state renders distinct, queryable text. The empty state is the list with no items, which an extra check can confirm when the API returns an empty array.
A component should show a clear no-results message for that branch so the test can assert it without guessing.
Wait for success with findBy
The findBy queries combine a get query with a retry loop. They return a promise that resolves when the element appears. That makes them the default choice whenever a render depends on data that has not loaded yet, because a plain get query would throw before the data arrives.
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import UserList from "./UserList";
test("shows users after the fetch resolves", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: "Ada" }],
}));
render(<UserList />);
expect(await screen.findByText("Ada")).toBeVisible();
});The test mocks the network so it runs fast and predictably. The findByText query keeps checking until the list renders, then the assertion confirms the name is visible.
Both findBy and waitFor give up after 1000 milliseconds by default and check every 50 milliseconds, so pass a timeout option to raise that limit for slower operations. For a full network mock setup, follow how to mock API requests with MSW.
Wait for the loading state to disappear
The waitForElementToBeRemoved helper waits until an element is gone. A deferred promise keeps the loading text on screen long enough to observe it.
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import { vi } from "vitest";
import UserList from "./UserList";
test("removes the loading text when data arrives", async () => {
let resolveFetch;
vi.stubGlobal("fetch", vi.fn().mockReturnValue(
new Promise((resolve) => { resolveFetch = resolve; })
));
render(<UserList />);
expect(screen.getByText("Loading users")).toBeVisible();
resolveFetch({ ok: true, json: async () => [{ id: 1, name: "Ada" }] });
await waitForElementToBeRemoved(() => screen.queryByText("Loading users"));
});The loading text is present first. After the promise resolves, React re-renders without it, and the helper resolves once the element is gone.
The queryBy prefix returns null instead of throwing, which is what makes it safe to pass into the helper while the element may still be on screen. The query passed to it must find an element that exists before the wait starts.
Test the error state
A rejected fetch should render an alert. The findByRole query waits for that alert to appear, and the error branch is the one most likely to be forgotten, so a dedicated test guards the user against a blank screen.
test("shows an error when the fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
render(<UserList />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load users.");
});The alert role announces the failure to screen readers, which makes the error testable through the same semantics a user relies on. This test lives in the same file as the previous one and reuses its imports.
A deferred promise in a test lets you hold the loading state open while you assert it, then resolve it to watch the transition happen. That is how the loading example above observes both states in one deterministic run.
Use waitFor for non-element conditions
When the thing being awaited is not a single visible element, use waitFor directly. It re-runs its callback until the callback stops throwing. This is useful for checking a side effect such as a mocked function call, where there is no DOM element to query.
import { render, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import UserList from "./UserList";
test("starts one request on mount", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal("fetch", fetchMock);
render(<UserList />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
});The assertion lives inside the callback, because a thrown assertion is what triggers a retry. A returned promise is awaited before the next retry, so this also works for conditions that resolve in stages. For the query side of these waits, review how to query elements in React Testing Library.
Common mistakes
- Using a get query on content that appears after a fetch, which throws before the data arrives.
- Forgetting await, so the test finishes before the component updates.
- Passing a query that finds nothing to waitForElementToBeRemoved, which throws immediately.
- Hitting a real API in a unit test, making it slow and flaky.
Each mistake hides a timing issue rather than a logic issue, which is exactly why the async utilities exist in the first place.
What to learn next
If the async logic lives inside a custom hook, see how to test React hooks. Network mocking is covered separately in the MSW guide.
Rune AI
Key Insights
- Use findBy queries to wait for an element that appears after async work.
- Use waitForElementToBeRemoved to wait for a loading indicator to go away.
- Use waitFor for conditions that are not a single visible element.
- Always await these utilities, and default timeouts are 1000ms.
- Mock the network so tests stay fast and deterministic.
Frequently Asked Questions
What is the difference between findBy and getBy?
How long do async utilities wait by default?
Conclusion
Async components need async utilities. Use findBy when an element appears, waitForElementToBeRemoved when one disappears, and waitFor for any other retryable condition.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.