Mocking network requests in Storybook with the MSW addon
This page is part of the addon ecosystems section of the Storybook isolation workflows guide, which covers the addons worth their start-up cost.
The specific problem: a component fetches its own data. In Storybook it hits whatever API the dev server proxies to, so the story shows different content each day and fails entirely in CI where that API is unreachable.
Problem statement
A story is supposed to be a fixed rendering of a component in a declared state. A component that fetches makes that impossible: the state is whatever the network returned, which varies by environment, by day, and by whether anyone has been editing the seed data.
The symptom is a story that renders an empty list in CI, a spinner forever on a colleague’s machine, and three records on yours — and a visual baseline captured from it that diffs constantly.
Root cause
The fetch is inside the component, so there is no prop or context to inject through. The mock boundary has to be the network itself, and in a browser that means intercepting at the service-worker layer.
msw-storybook-addon does exactly that: it registers a service worker in the preview iframe and lets each story declare the responses it wants through a parameter. The component’s own fetching code — including its error handling, parsing, and retry behaviour — stays under test.
Minimal reproduction
// UserList.tsx — fetches its own data
export function UserList() {
const { data, error, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorCard />;
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
// the story has no control over what renders
export const Default: Story = {};
Step-by-step fix
1. Install and initialise the addon
npm install -D msw msw-storybook-addon
npx msw init public/ --save # writes the service worker into the static dir
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';
// erroring on an unhandled request means an unmocked endpoint is impossible to miss
initialize({ onUnhandledRequest: 'error' });
export const loaders = [mswLoader];
What this does: registers the worker before any story renders, and makes a request nobody mocked fail loudly rather than reach the network.
2. Declare handlers per story
import { http, HttpResponse, delay } from 'msw';
const users = [
{ id: '1', name: 'Ada Lovelace' },
{ id: '2', name: 'Grace Hopper' },
{ id: '3', name: 'Katherine Johnson' },
];
export const Loaded: Story = {
parameters: {
msw: { handlers: [http.get('/api/users', () => HttpResponse.json(users))] },
},
};
What this does: the story now states its own data, so it renders identically on every machine.
3. Add the states the real API cannot produce on demand
export const Empty: Story = {
parameters: { msw: { handlers: [http.get('/api/users', () => HttpResponse.json([]))] } },
};
export const ServerError: Story = {
parameters: {
msw: { handlers: [http.get('/api/users', () => new HttpResponse(null, { status: 500 }))] },
},
};
export const SlowNetwork: Story = {
parameters: {
msw: {
handlers: [http.get('/api/users', async () => {
await delay(2000);
return HttpResponse.json(users);
})],
},
},
};
What this does: makes the error and empty states browsable and snapshot-able, which is where most visual regressions actually hide.
4. Share the default handlers across stories
// .storybook/preview.ts — a baseline every story inherits
export const parameters = {
msw: { handlers: { auth: http.get('/api/session', () => HttpResponse.json({ user: users[0] })) } },
};
What this does: stops every story restating the session endpoint. A story that needs a different session overrides just that key.
5. Give the fetching library test-friendly defaults
// a retrying client turns a deliberate 500 story into a slow one
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
What this does: makes the error story render its error state immediately instead of retrying three times first.
Verification
Open the browser network panel with a story selected: the request should appear as served by the service worker rather than reaching the network. If it reaches the network, the worker file is missing from the static directory — re-run msw init against the directory Storybook actually serves.
Edge cases and caveats
-
The worker file must be in the served directory.
msw init public/writes it where Vite copies static assets from; a project using a different static directory needs the path adjusted, or the worker 404s and every request passes straight through. -
Handler order. MSW matches the first handler whose predicate fits, so a broad wildcard registered before a specific path will shadow it. Register specific paths first.
-
GraphQL. The same addon handles it with
graphql.query(...)instead ofhttp.get(...). Matching on operation name rather than URL is what makes it work, since every operation shares one endpoint.
One fixture set, four consumers
The reason to invest in mocked stories rather than mocked unit tests alone is that a story is read by more than one tool. Once UserList/ServerError exists, four things get it for free.
A reviewer can open the error state in the browser and see what a failed load actually looks like — the state that ships most often untested because reproducing it by hand means breaking an API.
The test-runner mounts it and runs whatever play function it carries, so an assertion about the retry button being focusable becomes a CI gate rather than a note in a review.
A visual baseline captures it, so a change to the error card’s spacing is caught by the same mechanism that catches changes to the loaded state. Error states are where visual regressions hide longest, precisely because nobody looks at them.
And the documentation page lists it alongside the others, so a consumer of the design system discovers that the component handles errors at all.
That leverage is what justifies keeping the fixture data in a shared module rather than inline in each story. fixtures/users.ts exporting threeUsers, noUsers and malformedUser is imported by the stories, the unit tests, and any contract test that validates them against the schema — so a field the backend adds is added once and propagates everywhere it is rendered.
FAQ
Does the MSW addon work in the test-runner as well as the browser?
Yes — the handlers are declared as story parameters, so they apply wherever the story is mounted. That is the property that makes this worth doing: one declaration serves the browsable story, the test-runner assertion, and the visual baseline, and none of the three can drift from the others.
Should stories and unit tests share handlers?
Share the fixture data, and let each declare its own handlers. The data is the thing that must not diverge — a user object that means one thing in a story and another in a test is a bug waiting to happen. The handlers themselves differ in shape between msw/browser and msw/node, so trying to share them tends to produce more indirection than it saves.
What happens to a request nobody mocked?
With onUnhandledRequest: 'error' it fails immediately, naming the URL. That is the setting worth keeping: the alternative is a request that silently reaches the network, works locally because a dev server proxies it, and fails in CI where nothing does.
Can I use the addon to prototype against an API that does not exist yet?
Yes, and it is one of the better uses of it. Writing the handlers from the agreed schema lets the component be built, reviewed and snapshotted before the endpoint ships, and validating the fixtures against that schema means the day the real service arrives you find out immediately whether it matches what you built against.
Related
- Addon Ecosystems — the parent section on choosing and ordering Storybook addons
- Mock Boundaries — the same interception model applied to a unit-test suite
- Interaction Testing — asserting on the states these mocked responses produce
- Test-Runner Automation — running the stories these handlers make deterministic