Abstracting Network Layers for Frontend Apps
Components that call fetch or Axios directly are the single biggest obstacle to reliable local development simulation. When a component owns its own HTTP calls, toggling between mock and live backends requires touching every component — and a single missed call causes flaky tests or silent failures in CI. This page shows you how to place a typed adapter layer between your components and the transport stack so you can swap the entire backend with one environment variable, zero component edits.
Why Components That Own HTTP Calls Break Local Simulation
The problem occurs at the intersection of two common design choices: UI components that import axios or call fetch directly, and a development environment that has no reliable backend to hit. The moment VITE_API_BASE_URL points at localhost:3001 and that service is down, the component crashes — even if the feature you are developing has nothing to do with that endpoint.
A network abstraction layer, as covered in network layer abstraction, moves all transport decisions out of component code and into a single boundary. The request interception pattern governs how traffic is captured at that boundary; this page gives you the concrete adapter wiring to activate it in a real React, Vue, or Angular project.
Architecture: The Adapter Boundary
The diagram below shows the call flow with and without the abstraction. Without it, components talk directly to the transport stack. With it, every outbound call passes through a single IHttpClient boundary where the environment decides which adapter handles it.
Solution — Step-by-Step
Step 1: Define the IHttpClient contract
Create a single TypeScript interface file. Every other file imports this type — nothing imports fetch or axios directly:
// src/lib/http/IHttpClient.ts
export interface ApiResponse<T> {
data: T;
status: number;
headers: Record<string, string>;
}
export interface RequestOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export interface IHttpClient {
get<T>(path: string, options?: RequestOptions): Promise<ApiResponse<T>>;
post<T>(path: string, body: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
put<T>(path: string, body: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
delete<T>(path: string, options?: RequestOptions): Promise<ApiResponse<T>>;
}
Step 2: Build the live transport adapter
The FetchAdapter wraps the native fetch API and maps every response to ApiResponse<T>. Throwing on non-2xx status codes gives consumers a single error-handling path:
// src/lib/http/FetchAdapter.ts
import type { ApiResponse, IHttpClient, RequestOptions } from './IHttpClient';
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
export class FetchAdapter implements IHttpClient {
private async request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): Promise<ApiResponse<T>> {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: options?.signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${method} ${path}`);
}
const data: T = await res.json();
const headers: Record<string, string> = {};
res.headers.forEach((value, key) => { headers[key] = value; });
return { data, status: res.status, headers };
}
get<T>(path: string, options?: RequestOptions) {
return this.request<T>('GET', path, undefined, options);
}
post<T>(path: string, body: unknown, options?: RequestOptions) {
return this.request<T>('POST', path, body, options);
}
put<T>(path: string, body: unknown, options?: RequestOptions) {
return this.request<T>('PUT', path, body, options);
}
delete<T>(path: string, options?: RequestOptions) {
return this.request<T>('DELETE', path, undefined, options);
}
}
Step 3: Build the mock adapter with a deterministic registry
The MockAdapter uses the same interface. It looks up an incoming path against a registry map, applies an optional latency profile, and returns the fixture. This is the boundary where response shaping techniques take effect — you can swap fixtures per test without changing adapter wiring:
// src/lib/http/MockAdapter.ts
import type { ApiResponse, IHttpClient, RequestOptions } from './IHttpClient';
export type MockHandler<T = unknown> = (path: string, body?: unknown) => ApiResponse<T>;
interface RegistryEntry {
handler: MockHandler;
latencyMs?: number;
}
export class MockAdapter implements IHttpClient {
private registry = new Map<string | RegExp, RegistryEntry>();
register<T>(matcher: string | RegExp, handler: MockHandler<T>, latencyMs = 0): void {
this.registry.set(matcher, { handler: handler as MockHandler, latencyMs });
}
private resolve(path: string, body?: unknown): RegistryEntry | undefined {
// Exact string match first, then RegExp, then wildcard '*'
for (const [matcher, entry] of this.registry) {
if (typeof matcher === 'string' && matcher !== '*' && matcher === path) return entry;
}
for (const [matcher, entry] of this.registry) {
if (matcher instanceof RegExp && matcher.test(path)) return entry;
}
return this.registry.get('*');
}
private async dispatch<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
const entry = this.resolve(path, body);
if (!entry) throw new Error(`MockAdapter: no handler registered for "${path}"`);
if (entry.latencyMs) {
await new Promise<void>(r => setTimeout(r, entry.latencyMs));
}
return entry.handler(path, body) as ApiResponse<T>;
}
get<T>(path: string, _options?: RequestOptions) { return this.dispatch<T>(path); }
post<T>(path: string, body: unknown, _options?: RequestOptions) { return this.dispatch<T>(path, body); }
put<T>(path: string, body: unknown, _options?: RequestOptions) { return this.dispatch<T>(path, body); }
delete<T>(path: string, _options?: RequestOptions) { return this.dispatch<T>(path); }
}
Step 4: Wire environment routing via React Context
Read VITE_MOCK_ENABLED once at application bootstrap and inject the correct adapter. No component imports an adapter directly:
// src/lib/http/HttpClientProvider.tsx
import React, { createContext, useContext, useMemo } from 'react';
import type { IHttpClient } from './IHttpClient';
import { FetchAdapter } from './FetchAdapter';
import { MockAdapter } from './MockAdapter';
// Populate the mock registry from a fixture map for local simulation.
function buildMockAdapter(): MockAdapter {
const adapter = new MockAdapter();
adapter.register('/api/users/123', () => ({
data: { id: '123', name: 'Ada Lovelace', role: 'admin' },
status: 200,
headers: { 'content-type': 'application/json' },
}), 200);
adapter.register(/^\/api\/products\/\d+$/, (path) => ({
data: { id: path.split('/').pop(), name: 'Widget', price: 9.99 },
status: 200,
headers: { 'content-type': 'application/json' },
}));
adapter.register('*', () => ({
data: null,
status: 404,
headers: {},
}));
return adapter;
}
const HttpClientContext = createContext<IHttpClient | null>(null);
export function HttpClientProvider({ children }: { children: React.ReactNode }) {
const client = useMemo<IHttpClient>(() => {
return import.meta.env.VITE_MOCK_ENABLED === 'true'
? buildMockAdapter()
: new FetchAdapter();
}, []);
return (
<HttpClientContext.Provider value={client}>
{children}
</HttpClientContext.Provider>
);
}
export function useHttpClient(): IHttpClient {
const client = useContext(HttpClientContext);
if (!client) throw new Error('useHttpClient must be used inside HttpClientProvider');
return client;
}
Wrap the application root once:
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HttpClientProvider } from './lib/http/HttpClientProvider';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HttpClientProvider>
<App />
</HttpClientProvider>
</React.StrictMode>,
);
Components call useHttpClient() instead of importing fetch:
// src/features/UserProfile.tsx
import { useEffect, useState } from 'react';
import { useHttpClient } from '../lib/http/HttpClientProvider';
interface User { id: string; name: string; role: string; }
export function UserProfile({ userId }: { userId: string }) {
const http = useHttpClient();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
http.get<User>(`/api/users/${userId}`).then(res => setUser(res.data));
}, [http, userId]);
if (!user) return <p>Loading…</p>;
return <p>{user.name} ({user.role})</p>;
}
Step 5: Set the environment flag
Create a .env.local file (never committed) for your local simulation environment. This is the only file that changes between mock and live modes:
# .env.local — local simulation, not committed
VITE_MOCK_ENABLED=true
VITE_API_BASE_URL=https://api.example.com
For the live integration environment:
# .env.production
VITE_MOCK_ENABLED=false
VITE_API_BASE_URL=https://api.example.com
The buildMockAdapter() function and all registered fixtures are tree-shaken out of the production bundle because the adapter is selected at runtime through the context, but modern bundlers can statically eliminate the mock branch if you split the factory into a separate dynamic import.
Verification
With VITE_MOCK_ENABLED=true in .env.local, run the dev server and open your browser’s network panel:
VITE_MOCK_ENABLED=true npx vite dev
Open the app, trigger the UserProfile component, and confirm in the Network tab that no request to api.example.com appears. The response data should be Ada Lovelace (admin) — the fixture registered for /api/users/123. If you see a real network call, the context provider is not wrapping the component tree or the env var is not being read correctly.
For automated verification in CI, add an assertion to your test suite:
// src/features/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { HttpClientProvider } from '../lib/http/HttpClientProvider';
import { UserProfile } from './UserProfile';
// Set the env flag before importing the provider.
beforeAll(() => {
import.meta.env.VITE_MOCK_ENABLED = 'true';
});
test('renders user from mock registry without hitting live API', async () => {
render(
<HttpClientProvider>
<UserProfile userId="123" />
</HttpClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Ada Lovelace (admin)')).toBeInTheDocument();
});
});
Gotchas and Edge Cases
-
HMR leaves dangling adapter instances. Vite replaces modules on hot reload but does not re-run
main.tsxin full, so a secondHttpClientProvidercan mount alongside the first. Guard against it by makingbuildMockAdapter()return the same instance via a module-level variable and calling cleanup inimport.meta.hot?.dispose(() => { /* clear the registry */ }). -
Auth headers are lost when switching adapters. If your
FetchAdapterattaches a Bearer token from an auth context, theMockAdapterreceives the same call with no token — which is fine for simulation but can mask a real auth bug. During integration testing, passoptions.headersthrough to the mock entry’s handler so you can assert that the component is sending the token. -
Env variable evaluated at bundle time in some configs. Vite replaces
import.meta.env.*at build time by default. If you try to overrideVITE_MOCK_ENABLEDat runtime (e.g. in a Vitest setup file), the value may already be inlined. Usevi.stubEnv('VITE_MOCK_ENABLED', 'true')in Vitest, or switch to a runtime-readable configuration object for environments where you need dynamic control.
FAQ
Does this pattern work with Vue’s Composition API or Angular’s dependency injection?
Yes. In Vue, replace the React Context provider with a provide/inject pair at the app root — app.provide('httpClient', client) — and inject it in composables with const http = inject<IHttpClient>('httpClient'). In Angular, register the adapter as a provider in the root AppModule or provideHttpClient-equivalent, and switch between the mock and live class using an environment-based factory provider.
Will this abstraction break HMR in Vite or webpack dev server?
Only if you register the mock adapter as a module-level singleton without a teardown path. Enforce a singleton pattern with a cleanup function, and call that cleanup inside Vite’s import.meta.hot.dispose callback to avoid dangling interceptors across hot reloads.
Can I use this pattern in a micro-frontend architecture?
Yes. Publish the IHttpClient interface and the environment-routing factory as a shared package. Each micro-frontend imports and instantiates it independently, so each shell can toggle mock/live routing without affecting siblings.
How do I validate that mock responses match the real API contract?
Add a CI step that runs your OpenAPI spec validator against every response fixture registered in the mock registry. Any fixture that diverges from the spec fails the pipeline before the drift reaches production. This practice prevents the response shaping techniques you apply locally from diverging silently from the production schema.
Related
- Network Layer Abstraction — the parent topic covering interception boundaries across different transport stacks
- How to Intercept Fetch Requests in React — a lower-level alternative that intercepts at the
fetchglobal rather than behind an adapter - Best Practices for Dynamic Response Shaping — how to generate realistic fixture payloads for the mock registry
- When to Use Proxy vs Inline Mocking — decision guide for choosing between adapter-based inline mocking and a network-level proxy
← Back to Network Layer Abstraction