How to Intercept Fetch Requests in React

React components call window.fetch (or an abstraction over it) whenever they load remote data. During local development and integration testing, those calls reach real — or unavailable — backend services, causing flaky behaviour and environment coupling. This page explains how to intercept every outbound fetch call at the browser level so your components render deterministically against controlled responses.

Context: why fetch interception is tricky in React

Three React-specific factors make fetch interception harder than a simple window.fetch = myWrapper line:

  1. Concurrent rendering. React 18+ may fire the same data-fetching effect multiple times in Strict Mode, and Suspense can trigger fetches before a component is fully committed to the DOM. An interceptor that mutates shared state on each call can cause race conditions.
  2. Multiple fetch callers. When using response shaping techniques with a library such as React Query or SWR, the library manages the fetch lifecycle — not your component directly. The patch must be in place before the QueryClientProvider or equivalent initialises.
  3. Teardown order. A React useEffect cleanup runs when the component unmounts, but React’s batching may defer cleanup relative to the next render cycle. If the patch is not restored atomically, a subsequent test or page navigation picks up the wrapped fetch and may return stale mock data.

The most robust solution for browser environments is Mock Service Worker (MSW), which intercepts at the Service Worker layer and never touches window.fetch. The manual window.fetch wrapper shown below is the correct fallback when Service Workers are unavailable — for example, in a Node-based test runner like Vitest, in an SSR context, or in an environment without HTTPS.

Fetch interception flow in a React app Diagram showing how a fetch call from a React component is evaluated: it either matches a mock registry and returns a synthetic Response, or is delegated to the real network via the original fetch reference. React Component calls fetch(url) window.fetch patch getMockForRoute( url, method) match Synthetic Response new Response(JSON…) no match originalFetch real network call useEffect cleanup restores window.fetch = originalFetch on unmount

Solution

Step 1 — Build the mock registry

Define your mock routes in a dedicated module so the interceptor itself stays free of data concerns:

// src/mocks/registry.ts
export interface MockRoute {
  method: string;
  pathname: string;
  status?: number;
  data: unknown;
}

const routes: MockRoute[] = [
  {
    method: 'GET',
    pathname: '/api/users',
    status: 200,
    data: [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ]
  },
  {
    method: 'POST',
    pathname: '/api/sessions',
    status: 201,
    data: { token: 'mock-jwt-token' }
  }
];

export function getMockForRoute(
  pathname: string,
  method: string
): MockRoute | undefined {
  return routes.find(
    r =>
      r.pathname === pathname &&
      r.method.toUpperCase() === method.toUpperCase()
  );
}

Step 2 — Write the fetch interceptor

// src/mocks/interceptor.ts
import { getMockForRoute } from './registry';

export function applyFetchInterceptor(): () => void {
  const originalFetch = window.fetch;

  window.fetch = async (
    input: RequestInfo | URL,
    init?: RequestInit
  ): Promise<Response> => {
    const url = new URL(
      input instanceof Request ? input.url : String(input),
      window.location.origin
    );

    // Clone any Request body before inspection to avoid consuming it
    const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
    const mockRoute = getMockForRoute(url.pathname, method);

    if (mockRoute) {
      // Respect abort signals even on mocked branches
      if (init?.signal?.aborted) {
        return Promise.reject(new DOMException('Aborted', 'AbortError'));
      }

      return new Response(JSON.stringify(mockRoute.data), {
        status: mockRoute.status ?? 200,
        headers: { 'Content-Type': 'application/json' }
      });
    }

    // Delegate non-matched requests to the real network
    return originalFetch(input, init);
  };

  // Return a teardown function for useEffect cleanup
  return () => {
    window.fetch = originalFetch;
  };
}

The function returns its own teardown so the caller holds no reference to originalFetch — preventing accidental double-restoration.

Step 3 — Activate in a top-level provider

Activate the interceptor at the application root, above any data-fetching library initialisation:

// src/providers/MockProvider.tsx
import { useEffect } from 'react';
import { applyFetchInterceptor } from '../mocks/interceptor';

interface MockProviderProps {
  enabled: boolean;
  children: React.ReactNode;
}

export function MockProvider({ enabled, children }: MockProviderProps) {
  useEffect(() => {
    if (!enabled) return;
    const teardown = applyFetchInterceptor();
    return teardown;
  }, [enabled]);

  return <>{children}</>;
}

Wrap your app in App.tsx or main.tsx:

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { MockProvider } from './providers/MockProvider';
import App from './App';

const isMockEnabled = import.meta.env.VITE_MOCK_API === 'true';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <MockProvider enabled={isMockEnabled}>
      <App />
    </MockProvider>
  </StrictMode>
);

Set VITE_MOCK_API=true in your .env.local to enable interception without touching any component code. This network layer abstraction keeps the mock concern entirely outside your business logic.

Step 4 — Handle CORS preflight for cross-origin routes

If any mocked URL is on a different origin, the browser fires an OPTIONS preflight before the real method. Intercept it explicitly:

// Add to getMockForRoute logic, or handle inline in the wrapper:
if (method === 'OPTIONS') {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization'
    }
  });
}

Step 5 — Integrate with React Query

React Query’s QueryClient calls window.fetch through its default queryFn. Because MockProvider mounts above QueryClientProvider, the patch is already in place when any query fires:

// src/main.tsx  (updated)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <MockProvider enabled={isMockEnabled}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </MockProvider>
  </StrictMode>
);

No changes to any query hook are required. The advanced MSW handler patterns page covers how to replicate loading states and error scenarios if you later migrate to MSW.

Where the interception happens in a React app A component effect calls fetch. The Service Worker intercepts before the request leaves the runtime and matches it against the handler set. The handler returns a response, which resolves the original promise so the component's state update runs exactly as it would against a real API. The component is unmodified — which is the property that makes these tests worth anything. Component effect calls fetch unchanged code Service Worker intercepts before the network matched by URL and method Handler returns a response the same Response object State update runs identically the component cannot tell A component that has to know it is being mocked is testing the mock rather than the component.

Verification

Run this assertion in a Vitest or Jest test to confirm the interceptor is active and returning mock data:

// src/mocks/interceptor.test.ts
import { applyFetchInterceptor } from './interceptor';

test('GET /api/users returns mock payload', async () => {
  const teardown = applyFetchInterceptor();

  const res = await fetch('/api/users');
  const data = await res.json();

  expect(res.status).toBe(200);
  expect(data).toEqual([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]);

  teardown();

  // Confirm original fetch is restored
  expect(window.fetch.toString()).toContain('native code');
});

A passing test with the final native code assertion confirms both interception and correct teardown.

Gotchas and edge cases

  • Strict Mode double-invocation. React 18 Strict Mode mounts, unmounts, and remounts every effect in development. If applyFetchInterceptor is called twice before teardown, the second call wraps the already-wrapped fetch. The returned teardown restores only the reference it captured — so as long as each useEffect cleanup calls its own teardown, the stack unwinds correctly. Never share a teardown reference across multiple effects.
  • Request body consumption. When logging or inspecting a Request object before deciding whether to mock it, always call req.clone() first. Reading .json() or .text() on the original consumes the body stream; the fallback originalFetch(input, init) will then receive a request with an empty body and fail at the server.
  • SSR and Next.js environments. window is undefined during server-side rendering. Guard any reference to window.fetch with typeof window !== 'undefined', or apply the interceptor only inside a useEffect (which never runs on the server). For Next.js App Router, the MSW setup for Next.js page covers the additional configuration required.

FAQ

Does this approach work with SWR?

Yes. SWR calls the global fetch by default. As long as MockProvider mounts above the SWR cache provider, every useSWR hook will receive the intercepted response without any per-hook configuration.

How do I simulate network delays with the manual wrapper?

Wrap the synthetic Response construction in a setTimeout inside a Promise:

if (mockRoute) {
  await new Promise(resolve => setTimeout(resolve, mockRoute.delay ?? 0));
  return new Response(JSON.stringify(mockRoute.data), { status: mockRoute.status ?? 200, headers: { 'Content-Type': 'application/json' } });
}

Add a delay field (in milliseconds) to your MockRoute interface and registry entries.

What happens if the interceptor throws?

An uncaught throw inside window.fetch rejects the Promise that the calling component awaits. React’s error boundaries will not catch asynchronous promise rejections by default — you need an onError handler in your React Query client or an ErrorBoundary paired with useErrorBoundary. Keep the interceptor’s match logic simple and synchronous to minimise this risk.

Can I use this in Playwright tests?

Playwright runs in a real browser context, so window.fetch patching works. However, Playwright also exposes page.route(), which intercepts at the network layer rather than the JS layer — it catches fetch, XHR, and subresource loads. For end-to-end tests, page.route() is more reliable than patching window.fetch. The proxy vs inline mocking strategies page covers the trade-offs between these two approaches.


Three React-specific traps Three problems specific to React. An effect firing twice under StrictMode doubles the requests. A query client caching across tests answers without a request at all. And an unawaited state update produces a warning that hides the real failure. Each names the fix. StrictMode double-invokes effects two requests where you asserted one assert on the rendered result, or account for it A shared query client caches the handler never runs at all a fresh client per render Unawaited state updates an act warning hides the real failure await the query with findBy, not getBy All three make an assertion about requests unreliable, which is why the rendered surface is the safer assertion.

← Back to Request Interception Patterns