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, validated against your Zod schemas. Parse mock data through its schema using .strict() to catch any mismatches immediately—if your model changes, your mocks should fail fast rather than silently drift. For more on schema variations and strict enforcement, see Data Model handling in TypeScript.
// 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.