Mocking in Storybook
How to mock props, providers, hooks, third-party modules, APIs, routing, state stores, browser APIs, and environment variables in Storybook, then verify the result with interaction tests across every variation.

Storybook started as a component catalog. But these days it handles testing, accessibility, documentation, and even full interaction testing flows. And once you add a play function to a story, every dependency the component reaches for has to be mocked.
Pure, dependency-free components are a nice ideal. Real components call APIs, depend on providers and hooks, and expect specific props or context. Mock all of them and you can test the component in isolation, with every branch covered.
Mocking props
Let’s start with the simplest scenario: a component that only depends on a couple of props.
// UserProfile.jsx
export function UserProfile({ user, onEdit }) {
return (
<div>
<h1>{user.name}</h1>
<button onClick={onEdit}>Edit</button>
</div>
);
}
We can mock props by passing values directly through args. We can define them at the top level or override them per story.
// UserProfile.stories.jsx
import { UserProfile } from "./UserProfile";
export default {
component: UserProfile,
args: {
onEdit: () => alert("Edit clicked"),
},
};
export const Default = {
args: {
user: { name: "Gandalf" },
},
};
export const LongUserName = {
args: {
user: { name: "Peregrin Took of the Shire" },
},
};
Default args give every story a shared baseline, and per-story args handle the variations.
Mocking context providers
Providers are also simple, but a bit more interesting.
// UserProvider.jsx
import { createContext, useContext } from "react";
const defaultUser = { name: "Original User" };
const defaultOnEdit = () => console.log("Original edit action");
const UserContext = createContext({
user: defaultUser,
onEdit: defaultOnEdit,
});
export const useUser = () => useContext(UserContext);
export const UserProvider = ({
user = defaultUser,
onEdit = defaultOnEdit,
children,
}) => (
<UserContext.Provider value={{ user, onEdit }}>
{children}
</UserContext.Provider>
);
To mock this provider in Storybook, we wrap the story in a decorator that injects mocked values through parameters.
// UserProfile.stories.jsx
import { UserProfile } from "./UserProfile";
import { UserProvider } from "./UserProvider";
export default {
component: UserProfile,
decorators: [
(Story, context) => {
const { user, onEdit } = context.parameters.userContext || {};
return (
<UserProvider user={user} onEdit={onEdit}>
<Story />
</UserProvider>
);
},
],
};
export const Default = {
parameters: {
userContext: {
user: { name: "Frodo Baggins" },
onEdit: () => alert("Editing Frodo"),
},
},
};
export const LongUserName = {
parameters: {
userContext: {
user: { name: "Peregrin Took of the Shire" },
onEdit: () => alert("Editing Pippin"),
},
},
};
If the mocks grow too long, import them from external files or use a factory pattern.
Mocking custom hooks the Storybook way
For custom hooks, Storybook supports a .mock.js or .mock.ts file placed alongside the original module. To mock a hook named useUser, create a file next to it called useUser.mock.js.
A few rules matter here:
- Import the original module using a relative path. Avoid aliases or subpaths, or the mock will import itself.
- Re-export everything from the original module to preserve all other exports.
- Use the
fnutility from@storybook/testto mock specific functions. - Use
.mockName()to retain readable names in stack traces and minified builds. - Avoid side effects. Mock files should only affect the module they target.
Here is the original hook:
// useUser.js
export const useUser = () => {
return {
user: { name: "Original User" },
onEdit: () => console.log("Original edit action"),
};
};
Re-export everything from the original module, then mock with fn and mockName:
// useUser.mock.js
import { fn } from "@storybook/test";
export * from "./useUser";
export const useUser = fn(() => ({
user: { name: "Mocked User" },
onEdit: fn().mockName("onEdit"),
})).mockName("useUser");
In the story, the mocked version is imported automatically, but we can still override it per variant:
// MyComponent.stories.js
import MyComponent from "./MyComponent";
import { useUser } from "../lib/useUser"; // imports the mocked version
export default {
component: MyComponent,
};
export const Another = {
render: () => {
// Customize the mock just for this story
useUser.mockReturnValue({
user: { name: "Storybook User" },
onEdit: () => alert("Mocked edit action"),
});
return <MyComponent />;
},
};
This way, we can mock any file the component pulls in directly. No rewriting, no context wrapping.
Mocking third parties
Sometimes we need to mock external libraries like uuid, date-fns, or axios. The same .mock.js pattern works here too.
// uuid.mock.js
import * as actual from "uuid";
export * from "uuid";
export const v4 = () => "mocked-uuid";
Notice that we re-export everything from the real uuid package. This ensures that any function we do not need to mock still works normally.
Mocking with Jest-like syntax
If you are already familiar with Jest, you can also mock modules in Storybook using the community addon storybook-addon-module-mock. It lets us use jest.mock()-style mocking directly inside our stories.
Let’s reuse the same hook example:
// useUser.js
export const useUser = () => ({
user: { name: "Original User" },
onEdit: () => console.log("Real edit action"),
});
Inside the story, we can mock the hook like this:
// MyComponent.stories.js
import MyComponent from "./MyComponent";
import { mockModules } from "storybook-addon-module-mock";
export const MockedHook = {
render: async () => {
await mockModules({
"./useUser": {
useUser: () => ({
user: { name: "Mocked User" },
onEdit: () => alert("Mocked edit action"),
}),
},
});
return <MyComponent />;
},
};
Mocking APIs
Time to mock APIs. Intercept the call with MSW and return whatever data you want, no rewriting required. The same setup works whether the request happens inside a component or a custom hook.
// Component.jsx
import { useEffect, useState } from "react";
export default function Component() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then(setUser);
}, []);
return <p>Hello, {user?.name || "loading"}!</p>;
}
We intercept the network request and return mock data through the msw parameter:
// Component.stories.js
import Component from "./Component";
import { rest } from "msw";
export default {
component: Component,
title: "Component",
parameters: {
msw: [
rest.get("/api/user", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ name: "Mocked User" }));
}),
],
},
};
export const Default = {};
If the request had happened inside a hook instead of the component, the same setup would still work. Switch to ctx.status(500) to mock an error, or ctx.status(204) for an empty response.
Mocking GraphQL
GraphQL is just as easy. We intercept the request and return mocked data, but we use the msw.graphql helper instead.
// Component.stories.js
import Component from "./Component";
import { graphql } from "msw";
export default {
component: Component,
title: "Component",
parameters: {
msw: [
graphql.query("GetUser", (req, res, ctx) => {
return res(
ctx.data({
user: { name: "Mocked User" },
}),
);
}),
],
},
};
export const Default = {};
Mocking routing
Routing is one of those dependencies that looks harmless until you try to render a component in isolation. A useParams call deep in the tree throws, a Link renders without a router context, and the story crashes on a blank screen.
For react-router v6+, the cleanest fix is a decorator that wraps the story in a MemoryRouter with the initialEntries we want to test.
// ProductPage.stories.jsx
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ProductPage } from "./ProductPage";
export default {
component: ProductPage,
decorators: [
(Story, context) => (
<MemoryRouter
initialEntries={[`/products/${context.parameters.productId}`]}
>
<Routes>
<Route path="/products/:id" element={<Story />} />
</Routes>
</MemoryRouter>
),
],
};
export const Default = {
parameters: { productId: "42" },
};
This lets useParams, useNavigate, and Link all work as if the user had landed on /products/42. We get the real router, just with a fake URL.
For components that only call useRouter or usePathname from next/navigation, module mocking is usually simpler than spinning up the Next router. It also reuses the same .mock.js pattern we already saw for hooks:
// next/navigation.mock.js
import { fn } from "@storybook/test";
export const useRouter = fn(() => ({
push: fn().mockName("router.push"),
replace: fn().mockName("router.replace"),
back: fn().mockName("router.back"),
})).mockName("useRouter");
export const usePathname = fn(() => "/products/42").mockName("usePathname");
export const useSearchParams = fn(
() => new URLSearchParams("?ref=email"),
).mockName("useSearchParams");
Drop the file next to the import and the story picks it up automatically. No decorator required.
Mocking state stores
State stores are a frequent source of “it works in the app, it breaks in Storybook” moments. The component reads from a Redux selector or a Zustand hook, and without the store it either crashes or renders blanks.
For Redux, the classic move is a decorator that wraps the story in a Provider with a mock store built from redux-mock-store:
// CartButton.stories.jsx
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import { CartButton } from "./CartButton";
const mockStore = configureStore([]);
export default {
component: CartButton,
decorators: [
(Story, context) => {
const state = context.parameters.storeState || {};
return (
<Provider store={mockStore(state)}>
<Story />
</Provider>
);
},
],
};
export const WithThreeItems = {
parameters: {
storeState: { cart: { items: ["a", "b", "c"] } },
},
};
Each story now describes a slice of state instead of a setup routine. The same decorator works for every Redux-connected component in the codebase.
Zustand is hook-based, so the decorator pattern does not fit. Use a .mock.js file that replaces the store module with a stub returning canned state:
// useCartStore.mock.js
import { fn } from "@storybook/test";
export const useCartStore = fn((selector) =>
selector({ items: ["a", "b", "c"] }),
).mockName("useCartStore");
For Vue projects using Pinia, the same idea applies. A decorator that calls setActivePinia(createPinia()) per story keeps state isolated. The principle is identical across all three libraries: supply the store, do not let the component reach for a global one.
Mocking browser APIs
jsdom does not implement most browser APIs. window.matchMedia, IntersectionObserver, ResizeObserver, and even localStorage are either missing or half-implemented, so a component that uses them will fail in Storybook for reasons that have nothing to do with the component itself.
The fix is to stub these once, globally, in preview.tsx. We use Object.defineProperty so the stub survives strict-mode reads, and we wrap the methods in fn() so interaction tests can still assert against them later.
// .storybook/preview.tsx
import { fn } from "@storybook/test";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: fn()
.mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: fn().mockName("addListener"),
removeListener: fn().mockName("removeListener"),
addEventListener: fn().mockName("addEventListener"),
removeEventListener: fn().mockName("removeEventListener"),
dispatchEvent: fn().mockName("dispatchEvent"),
}))
.mockName("matchMedia"),
});
class IntersectionObserverStub {
observe = fn().mockName("observe");
unobserve = fn().mockName("unobserve");
disconnect = fn().mockName("disconnect");
takeRecords = fn().mockName("takeRecords");
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: IntersectionObserverStub,
});
const storage = new Map();
Object.defineProperty(window, "localStorage", {
writable: true,
value: {
getItem: fn((key) => storage.get(key) ?? null).mockName("getItem"),
setItem: fn((key, value) => storage.set(key, String(value))).mockName(
"setItem",
),
removeItem: fn((key) => storage.delete(key)).mockName("removeItem"),
clear: fn(() => storage.clear()).mockName("clear"),
},
});
Once these are in place, components that respond to dark mode, lazy-load with IntersectionObserver, or read a cached value from localStorage just work. No per-story setup required. ResizeObserver follows the same shape as IntersectionObserver, so we will not repeat it here.
Mocking environment variables
Feature flags are usually environment variables injected at build time, like process.env.REACT_APP_FEATURE_X in CRA or import.meta.env.VITE_FEATURE_X in Vite. In Storybook, those values either come from the build config or they do not come at all, which makes it hard to exercise both sides of a flag from the same stories.
A decorator that rewrites the env object for the duration of the story is the cleanest fix. Since env access happens through a single global, we can swap it before render and the next story overwrites it again.
// FeatureBanner.stories.jsx
import { FeatureBanner } from "./FeatureBanner";
export default {
component: FeatureBanner,
decorators: [
(Story, context) => {
const { flags } = context.parameters.env || {};
Object.assign(import.meta.env, flags);
return <Story />;
},
],
};
export const FlagOn = {
parameters: {
env: { flags: { VITE_NEW_DASHBOARD: "true" } },
},
};
export const FlagOff = {
parameters: {
env: { flags: { VITE_NEW_DASHBOARD: "false" } },
},
};
For CRA, the same decorator targets process.env instead. The story reads as a description of the flag state, not as a setup script, which is the whole point.
One caveat: this only works if the component reads the env at render time, not at module load. A component that destructures const { VITE_NEW_DASHBOARD } = import.meta.env at the top of the file will have already captured the value before the decorator runs. When that happens, push the read inside the component.
Interaction tests with play
A play function is a small script that runs against a rendered story. It clicks, types, and asserts, much like a Testing Library test, but inside Storybook. The mocks we have been setting up are what give these tests meaning. Props, providers, hooks, APIs, routing, stores, env, all locked in place, so the play function only exercises the component’s own behavior.
Let’s go back to the UserProfile from the very first section and turn its onEdit prop into a fn() mock so we can assert on it:
// UserProfile.stories.jsx
import { UserProfile } from "./UserProfile";
import { userEvent, expect, fn, within } from "@storybook/test";
export default {
component: UserProfile,
args: {
user: { name: "Gandalf" },
onEdit: fn().mockName("onEdit"),
},
};
export const Editable = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /edit/i }));
await expect(canvas.getByText("Gandalf")).toBeInTheDocument();
},
};
That story now documents two things at once: what the component renders, and what happens when a user interacts with it. The play function runs in the Storybook UI as a small badge next to the story, green for pass and red for fail, and it runs again in CI, so the same artifact designers review is the one the pipeline verifies.
The pattern scales to every mock we have covered. A CartButton connected to a mock Redux store can be clicked, and the play function can assert that dispatch was called with the expected action:
// CartButton.stories.jsx
import { Provider } from "react-redux";
import configureStore from "redux-mock-store";
import { CartButton } from "./CartButton";
import { userEvent, expect, fn, within } from "@storybook/test";
const mockStore = configureStore([]);
const dispatch = fn().mockName("dispatch");
export default {
component: CartButton,
decorators: [
(Story) => {
const store = mockStore({ cart: { items: ["a"] } });
store.dispatch = dispatch;
return (
<Provider store={store}>
<Story />
</Provider>
);
},
],
};
export const AddsItem = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /add to cart/i }));
await expect(dispatch).toHaveBeenCalledWith({ type: "cart/add" });
},
};
Load a component behind MSW and the play function can wait for the mocked response to render before clicking through. Routing mocks let us assert that useNavigate was called with the right path. Browser API stubs let us simulate a viewport change and assert the component responded.
Without isolation, a play function is just a flaky click.
When not to mock
Mocking is a tool for isolation, not a virtue. The reflex “I see a dependency, therefore I mock it” is one of the fastest ways to end up with a test suite that passes while the app is broken.
A few anti-patterns are worth naming out loud.
The first is testing the mock. If a play function asserts that dispatch was called, that is a meaningful contract. If it asserts that dispatch was called with a payload that the mock itself constructed, the test is now circling a closed loop. It proves the mock agreed with itself, nothing more. We have written plenty of these by accident. They feel productive and catch nothing.
The second is shared-state leaks. fn() mocks created at the module level keep their mockReturnValue and call history across stories. A play function that asserts expect(onEdit).toHaveBeenCalled() will pass in story B because story A already clicked the button. That is not a passing test. That is a lie.
// shared-mock-anti-pattern.stories.jsx
import { expect, fn, within } from "@storybook/test";
import UserProfile from "./UserProfile";
const onEdit = fn().mockName("onEdit");
export default {
component: UserProfile,
args: { user: { name: "Gandalf" }, onEdit },
};
export const First = {};
export const Second = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(onEdit).toHaveBeenCalled();
},
};
Second passes even if its own button is never clicked, because First already invoked onEdit. The fix is to create the fn() inside args per story, or call mockClear() in a decorator, so each story starts with a clean mock.
The third is mocking so much that integration coverage disappears. If every provider, hook, store, and API is stubbed, the test runs in a vacuum that does not exist in production. The interaction between two real pieces is often where the bugs live, and a suite that has mocked them all away will never find them. The testing trophy argument applies here: prefer the smallest mock that makes the story runnable, not the largest one that makes it pure.
The fourth, and the most common, is mocking out of habit. Not every dependency needs to be mocked. A pure utility, a stable context, a presentational child, these can stay real, and the story will be more honest for it. The goal is a runnable, isolated story. The moment that goal is met, stop mocking.
Conclusion
Stop mocking the moment the story runs. Every extra mock is a seam where the test and the product diverge, and the “when not to mock” section is the one most people skip. The boundary is a judgment call — but now you have seen every shape it can take.


