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:
- 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.
- 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
QueryClientProvideror equivalent initialises. - Teardown order. A React
useEffectcleanup 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 wrappedfetchand 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.
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.
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
applyFetchInterceptoris called twice before teardown, the second call wraps the already-wrappedfetch. The returned teardown restores only the reference it captured — so as long as eachuseEffectcleanup calls its own teardown, the stack unwinds correctly. Never share a teardown reference across multiple effects. - Request body consumption. When logging or inspecting a
Requestobject before deciding whether to mock it, always callreq.clone()first. Reading.json()or.text()on the original consumes the body stream; the fallbackoriginalFetch(input, init)will then receive a request with an empty body and fail at the server. - SSR and Next.js environments.
windowis undefined during server-side rendering. Guard any reference towindow.fetchwithtypeof window !== 'undefined', or apply the interceptor only inside auseEffect(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.
Related
- Writing custom MSW response resolvers — handler patterns for stateful and conditional responses
- Abstracting network layers for frontend apps — keeping mock logic outside component code
- Best practices for dynamic response shaping — returning contextually realistic payloads
← Back to Request Interception Patterns