API Mocking Pattern for React and Storybook


A good API mocking strategy should:

  • Keep components clean — no conditional checks for process.env.STORYBOOK or 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 Api interface that both RealApi and MockApi implement; components only depend on the interface
  • Toggle via React Context — an ApiProvider switches implementations based on config; components stay unaware of which is active
  • In-memory store for stateful flowsMockApi maintains 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 scenariosmockUsers.newUser is clearer than createMockUser({...})
  • 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.