API Mocking Pattern for React and Storybook
A good API mocking strategy should:
- Keep components clean — no conditional checks for
process.env.STORYBOOKor test flags in business logic - Be consistent across stories — a single, shared approach rather than each story reimplementing its own mocking
- Support stateful flows — simulate multi-step interactions (submit → loading → success/error) without a backend
- Make edge cases easy to demonstrate — slow networks, intermittent failures, and empty states should be trivial to show
This pattern achieves these goals by creating a clean separation between your application code and mock infrastructure.
Solution Overview
- Single interface, two implementations — define an
Apiinterface that bothRealApiandMockApiimplement; components only depend on the interface - Toggle via React Context — an
ApiProviderswitches implementations based on config; components stay unaware of which is active - In-memory store for stateful flows —
MockApimaintains mutable state, enabling multi-step scenarios (e.g., create → edit → delete) without a backend - Typed, discoverable mock data — pre-built scenarios (
mockUsers.newUser) and factory functions (createMockUser()) live in a dedicated file, validated by TypeScript - Runtime controls for latency and errors — configurable delay and failure rate let you demonstrate loading states, error handling, and retry logic in Storybook or local dev
Core Concepts
1. API Interface
Define a contract that both real and mock implementations satisfy:
// lib/api/interface.ts
export interface Api {
getCurrentUser(): Promise<User>;
updateUser(data: Partial<User>): Promise<User>;
getItems(): Promise<Item[]>;
}
2. Mock Data (Typed and Validated)
Separate file for all mock data, typed to validate against models:
// lib/api/mock-data.ts
// Reference data
export const mockCategories: Category[] = [...];
// Factory functions for custom variations
export function createMockUser(overrides: Partial<User> = {}): User {
return { id: 'user-1', name: 'Jane', ...overrides };
}
// Pre-built scenarios (typed to validate against model)
export const mockUsers: Record<string, User> = {
newUser: { id: 'user-new', name: null, profileComplete: false, ... },
fullyComplete: { id: 'user-1', name: 'Jane', profileComplete: true, ... },
};
export const mockItems: Record<string, Item> = {
published: { id: 'item-1', status: 'published', ... },
draft: { id: 'item-2', status: 'draft', ... },
};
3. Mock Configuration
Store mock settings in localStorage with reactive updates:
// lib/api/mock-config.ts
export interface MockConfig {
enabled: boolean;
delayMs: number; // Simulate network latency
failureRate: number; // 0-1, probability of random failure
}
export function getMockConfig(): MockConfig { /* read from localStorage */ }
export function setMockConfig(config: Partial<MockConfig>): void { /* save + dispatch event */ }
export async function simulateMockBehavior<T>(config: MockConfig, fn: () => T): Promise<T> { ... }
4. Mock API with Store
The MockApi class owns its store, initialized with pre-built data:
// lib/api/mock-api.ts
import { mockUsers, mockItems } from "./mock-data";
interface MockStore {
currentUser: User | null;
items: Item[];
}
function createInitialStore(): MockStore {
return {
currentUser: mockUsers.fullyComplete,
items: Object.values(mockItems),
};
}
export class MockApi implements Api {
store: MockStore = createInitialStore();
private async mock<T>(fn: () => T): Promise<T> {
return simulateMockBehavior(getMockConfig(), fn);
}
resetStore(): void {
this.store = createInitialStore();
}
async getCurrentUser(): Promise<User> {
return this.mock(() => {
if (!this.store.currentUser) throw new Error("Not authenticated");
return this.store.currentUser;
});
}
}
export const mockApi = new MockApi();
5. API Context
React context that switches implementations based on config:
// lib/api/context.tsx
export function ApiProvider({ children, forceMock = false }: Props) {
const [config, setConfig] = useState<MockConfig>(() => getMockConfig());
useEffect(() => {
const handler = (e: CustomEvent) => setConfig(e.detail);
window.addEventListener('mock-config-changed', handler);
return () => window.removeEventListener('mock-config-changed', handler);
}, []);
const api = (forceMock || config.enabled) ? mockApi : realApi;
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
}
export function useApi(): Api {
return useContext(ApiContext);
}
Usage
In Components
function UserProfile() {
const api = useApi();
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: () => api.getCurrentUser(),
});
}
In Storybook
// .storybook/decorators.tsx
export const withMockedApi: Decorator = (Story) => (
<ApiProvider forceMock>
<QueryClientProvider client={new QueryClient()}>
<Story />
</QueryClientProvider>
</ApiProvider>
);
// Component.stories.tsx
import { withMockedApi } from "../../.storybook/decorators";
import { mockUsers, createMockUser } from "../lib/api";
export default { decorators: [withMockedApi] };
// Use pre-built scenario
export const NewUser: Story = {
args: { user: mockUsers.newUser },
};
// Use factory for custom variation
export const CustomUser: Story = {
args: { user: createMockUser({ name: "Custom Name" }) },
};
Mock Config Panel
Optional UI component for runtime control:
export function MockConfigPanel() {
const [config, setConfig] = useState(() => getMockConfig());
return (
<div className="fixed bottom-4 right-4">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => setMockConfig({ enabled: e.target.checked })}
/>
<input
type="range"
min="0" max="3000"
value={config.delayMs}
onChange={(e) => setMockConfig({ delayMs: +e.target.value })}
/>
<button onClick={() => mockApi.resetStore()}>Reset Data</button>
</div>
);
}
Key Benefits
- Typed mock data — TypeScript validates data against models at compile time
- Discoverable scenarios —
mockUsers.newUseris clearer thancreateMockUser({...}) - Separation of concerns — data in one file, behavior in another
- Flexible — factory functions still available for custom variations
- Stateful flows — test multi-step interactions without a backend
- Configurable — adjust latency and failure rates at runtime
- Clean components — components use
useApi()without mocking awareness
Bonus: Shared Reference Data
For reference data that must match between backend seeds and frontend mocks, use a shared package:
packages/shared/src/reference-data/
├── specialties.ts # { code, label }[]
├── medical-colleges.ts # { abbreviation, name, website }[]
├── preference-types.ts # enum + data array
└── index.ts
Both consumers import from the same source:
// Backend: packages/service/src/seeds/specialties.seed.ts
import { specialties } from "@locum/shared";
export const specialtiesData = specialties;
// Frontend: packages/webapp/src/lib/api/mock-data.ts
import { specialties } from "@locum/shared";
export const mockSpecialties = specialties.map((s) => ({
...s,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
}));
This ensures mock data always matches seeded database values.