React Loading States: How to Test With MSW
React components that fetch data display loading spinners or skeleton screens while the network request is in flight. Testing these states requires delaying the MSW response so your component has time to render the loading UI before the data arrives. You can then verify the spinner appears and later disappears once the data loads, ensuring your component handles the complete data-fetch lifecycle correctly.
I spent a week debugging a bug where my team's user profile component would flash a spinner and then disappear entirely on slow networks—the test passed because it ran instantly. Adding network delays to MSW revealed that the component unmounted before the fetch completed. MSW's response delay feature would have caught this immediately.
Adding Network Delays to Handlers
MSW allows you to introduce artificial delays in handlers by wrapping the response in a new Promise with setTimeout, or by using HttpResponse.json() with a delay option (though MSW's built-in delay is less common; the Promise approach is more idiomatic).
import { http, HttpResponse, delay } from "msw";
export const handlers = [
// Simulate a 2-second network delay
http.get("/api/user-profile/:id", async () => {
await delay(2000);
return HttpResponse.json({
id: "user-1",
name: "Alice Chen",
avatar: "https://example.com/alice.jpg",
});
}),
// Manual Promise-based delay (equivalent)
http.get("/api/posts", async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500)
);
return HttpResponse.json({
posts: [
{ id: "1", title: "First Post", content: "..." },
],
});
}),
];
The delay() function from MSW is a utility that returns a promise resolving after the specified milliseconds. Using it keeps your test code readable. For handlers that do not need delay, omit it entirely—MSW responds instantly by default.
Testing That a Spinner Appears
To verify a loading spinner appears, you check the DOM before the data arrives. Using getByRole() (which throws if not found) alongside queryByRole() (which returns null) lets you test presence and absence.
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse, delay } from "msw";
import { server } from "./mocks/server";
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(`/api/user-profile/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) {
return <div role="status" aria-label="Loading profile">
<span className="spinner" />
</div>;
}
return (
<div>
<h1>{user.name}</h1>
<img src={user.avatar} alt="User avatar" />
</div>
);
}
test("shows loading spinner while fetching user", async () => {
// Add a 2-second delay to the handler
server.use(
http.get("/api/user-profile/:id", async () => {
await delay(2000);
return HttpResponse.json({
id: "user-1",
name: "Alice Chen",
avatar: "https://example.com/alice.jpg",
});
})
);
render(<UserProfile userId="user-1" />);
// Spinner should appear immediately
const spinner = screen.getByRole("status", { name: /loading/i });
expect(spinner).toBeInTheDocument();
});
test("hides spinner after data loads", async () => {
server.use(
http.get("/api/user-profile/:id", async () => {
await delay(500);
return HttpResponse.json({
id: "user-1",
name: "Alice Chen",
avatar: "https://example.com/alice.jpg",
});
})
);
render(<UserProfile userId="user-1" />);
// Wait for the data to load (spinner disappears and name appears)
const name = await screen.findByText("Alice Chen");
expect(name).toBeInTheDocument();
// Spinner should now be gone
const spinner = screen.queryByRole("status");
expect(spinner).not.toBeInTheDocument();
});
getByRole() throws if the element does not exist, making the first assertion fail loudly if the spinner never renders. queryByRole() returns null, making the second assertion clean. findByText() waits up to 1000ms by default for the element to appear, perfect for testing async state updates.
Understanding waitFor vs findBy
Testing async state updates requires waiting for the DOM to update. Testing Library provides two approaches: waitFor() and findBy(). Understanding when to use each prevents race conditions in tests.
test("loads and displays user data", async () => {
server.use(
http.get("/api/user-profile/:id", async () => {
await delay(800);
return HttpResponse.json({
name: "Alice Chen",
email: "[email protected]",
});
})
);
render(<UserProfile userId="user-1" />);
// Method 1: Use findByText (recommended for single elements)
// It waits for the element to appear
const name = await screen.findByText("Alice Chen");
expect(name).toBeInTheDocument();
// Method 2: Use waitFor for more complex assertions
// It repeatedly runs the callback until it passes
await waitFor(() => {
const email = screen.getByText(/alice@acme.local/);
expect(email).toBeInTheDocument();
});
});
findByText() is syntactic sugar around waitFor(() => getByText(...)). Use findBy* queries when waiting for a single element to appear; use waitFor() when you need to verify multiple assertions together or wait for a component state change that does not result in a visible DOM element.
Testing Skeleton Screens and Placeholder Content
Skeleton screens (placeholder UI that mimics the final layout) are common in modern React apps. MSW delays let you test that skeletons appear and disappear correctly.
function ArticleList() {
const [articles, setArticles] = React.useState(null);
React.useEffect(() => {
fetch("/api/articles")
.then((res) => res.json())
.then(setArticles);
}, []);
if (!articles) {
return (
<div>
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-title" />
<div className="skeleton-body" />
</div>
))}
</div>
);
}
return (
<div>
{articles.map((article) => (
<article key={article.id}>
<h2>{article.title}</h2>
<p>{article.excerpt}</p>
</article>
))}
</div>
);
}
test("displays skeleton while loading, then real content", async () => {
server.use(
http.get("/api/articles", async () => {
await delay(1200);
return HttpResponse.json({
articles: [
{ id: "1", title: "Article One", excerpt: "First article..." },
{ id: "2", title: "Article Two", excerpt: "Second article..." },
],
});
})
);
render(<ArticleList />);
// Skeleton should be visible immediately
const skeletons = screen.getAllByRole("presentation");
expect(skeletons.length).toBeGreaterThan(0);
// Wait for real content to appear
const title = await screen.findByText("Article One");
expect(title).toBeInTheDocument();
// Skeleton should be gone
expect(screen.queryAllByRole("presentation")).toHaveLength(0);
});
By adding delays and testing both loading and loaded states, you catch bugs where components prematurely unmount or race conditions cause skeleton screens to flash unexpectedly.
Handling Race Conditions With MSW
Complex components that fetch multiple data sources can suffer race conditions where the second fetch completes before the first. MSW lets you simulate this by returning different delays for different endpoints.
export const handlers = [
// User data loads quickly
http.get("/api/user", async () => {
await delay(300);
return HttpResponse.json({ name: "Alice", id: "1" });
}),
// User's posts load slowly
http.get("/api/user/posts", async () => {
await delay(1500);
return HttpResponse.json({ posts: [{ id: "1", title: "..." }] });
}),
];
test("component handles staggered data loads", async () => {
render(<UserDashboard />);
// User name loads quickly
const name = await screen.findByText("Alice");
expect(name).toBeInTheDocument();
// Posts still loading
let postsSpinner = screen.getByRole("status", { name: /posts/i });
expect(postsSpinner).toBeInTheDocument();
// Wait for posts
const postTitle = await screen.findByText(/\.\.\./);
expect(postTitle).toBeInTheDocument();
// Posts spinner gone
postsSpinner = screen.queryByRole("status", { name: /posts/i });
expect(postsSpinner).not.toBeInTheDocument();
});
This test verifies that your component correctly displays partial data (user name) while still loading other data (posts), preventing the all-or-nothing loading state that confuses users.
Key Takeaways
- MSW's
delay()function adds artificial network latency, allowing you to test loading UI without waiting for real network calls. - Use
getByRole()to verify loading spinners are present immediately; usequeryByRole()to verify they disappear after data loads. findByText()waits up to 1000ms for an element to appear, making it ideal for testing async data rendering.waitFor()is useful for complex assertions that require multiple checks or state changes.- Testing multiple endpoints with staggered delays reveals race conditions and partial-load scenarios.
Frequently Asked Questions
Why does my test timeout even with a small delay?
The most common cause is that the component does not actually call fetch(). Verify with console.log() or a network breakpoint that the fetch is happening. Also check that your handler URL and method match exactly.
Can I make the delay configurable per test?
Yes, use server.use() to override handlers with different delays: server.use(http.get("/api/users", async () => { await delay(100); ... }))
What is a reasonable delay to use in tests?
Use 300-1000ms. Delays longer than 1500ms slow down your test suite. If testing genuine slow-network scenarios, 500ms is a good default that catches most issues without bloating test runtime.
Should I test loading states at all?
Yes. Loading UI bugs are common: spinners that never disappear, content that renders before loading is complete, or race conditions in staggered loads. MSW makes testing these scenarios trivial.