In modern web development with React, interacting with backend APIs is a fundamental task. While the browser’s built-in fetch
API provides the basic functionality, it can become repetitive and cumbersome to handle tasks like setting headers, managing authentication, and implementing consistent error handling across your application. This blog post will guide you through building a reusable custom fetch
client in React.

Why Build a Custom Fetch Client?
Native fetch
is flexible but barebones — it doesn’t handle common needs like:
- Adding auth tokens to every request
- Consistent error handling for different HTTP status codes
- Parsing JSON, text, or blobs automatically
- Centralized retry or logging logic
This client solves that with:
- Request interceptors (attach tokens, headers)
- Response interceptors (transform data, handle errors)
- Custom error formatting
- Helper methods for
GET
,POST
,PUT
,DELETE
, andPATCH
Core Features
At the core of our custom client lies the FetchInstance
class. This class acts as a wrapper around the native fetch
API, providing a structured way to make HTTP requests and handle responses.
class FetchInstance {
private baseURL: string;
private requestInterceptors: Array<(config: RequestConfig) => RequestConfig | Promise<RequestConfig>> = [];
private responseInterceptors: Array<(response: FetchResponse) => FetchResponse | Promise<FetchResponse>> = [];
private errorInterceptors: Array<(error: FetchError) => FetchError | Promise<FetchError> | void | Promise<void>> = [];
constructor(baseURL: string = import.meta.env.VITE_REACT_APP_BASE_URL) {
this.baseURL = baseURL;
}
/**
* Add request interceptor
*/
interceptors = {
// See full version at bottom
};
/**
* Execute request with interceptors
*/
private async executeRequest(config: RequestConfig): Promise<FetchResponse> {
// See full version at bottom
}
/**
* GET request
*/
async get<T = any>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<FetchResponse<T>> {
return this.executeRequest({ ...config, method: 'GET', url });
}
/**
* POST request
*/
async post<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'POST', url, body });
}
/**
* PUT request
*/
async put<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'PUT', url, body });
}
/**
* DELETE request
*/
async delete<T = any>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<FetchResponse<T>> {
return this.executeRequest({ ...config, method: 'DELETE', url });
}
/**
* PATCH request
*/
async patch<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'PATCH', url, body });
}
/**
* Generic request method
*/
async request<T = any>(config: RequestConfig): Promise<FetchResponse<T>> {
return this.executeRequest(config);
}
}
1. Request Configuration
The client defines a RequestConfig
type that extends RequestInit
(the native fetch
configuration object) with helpful extras like baseURL
and params
.
This ensures every request can be made with minimal configuration — you just specify the endpoint and any optional parameters, while the client handles the rest. For example, you can set a global baseURL
so you only pass the relative endpoint (/users
) in your calls. And response, error types are also added.
By typing the config, you also get IDE autocompletion and type safety, reducing mistakes.
baseURL
– default API baseurl
– endpoint pathparams
– query parameters
/**
* Interface for request configuration
*/
interface RequestConfig extends RequestInit {
baseURL?: string;
url?: string;
params?: Record<string, any>;
}
/**
* Interface for error response structure
*/
interface ErrorResponse {
message: string;
errorCode: string | null;
status: string;
statusCode: number;
timestamp: string;
errors: any[] | null;
path: string | null;
}
/**
* Interface for response structure
*/
interface FetchResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
config: RequestConfig;
}
/**
* Interface for error structure
*/
interface FetchError extends Error {
statusCode: number;
originalError: Error;
response?: Response;
request?: Request;
}
2. Default Error Messaging
A dedicated utility maps HTTP status codes to friendly, human-readable error messages. This avoids leaking raw API errors directly to end users and keeps messages consistent across the app.
For example, a 401
automatically returns “Authentication required. Please log in again.” while a 404
becomes “The requested resource was not found.”. This not only improves UX but also ensures that even unexpected API responses get handled gracefully.
The error mapping is extensible — you can add custom messages for domain-specific errors as your API evolves.
const getDefaultErrorMessage = (statusCode: number): string => {
switch (statusCode) {
case HTTP_401:
return 'Authentication required. Please log in again.';
case HTTP_403:
return 'Access denied. You do not have permission to access this resource.';
case HTTP_404:
return 'The requested resource was not found.';
case HTTP_405:
return 'Method not allowed. This operation is not supported.';
case HTTP_409:
return 'Conflict. The request conflicts with the current state of the resource.';
case HTTP_500:
return 'Internal server error. Please try again later.';
case HTTP_501:
return 'Not implemented. This feature is not available.';
case HTTP_502:
return 'Bad gateway. The server received an invalid response.';
case HTTP_503:
return 'Service unavailable. The server is temporarily unavailable.';
case HTTP_504:
return 'Gateway timeout. The request timed out.';
case HTTP_505:
return 'HTTP version not supported.';
default:
return 'An unexpected error occurred.';
}
};
3. Interceptors
One of the most powerful features is the ability to register request and response interceptors. Request interceptors let you modify or inspect the request before it’s sent — for example, attaching JWT tokens or setting default headers.
Response interceptors allow you to transform or log responses, or trigger special handling for specific status codes. This means you can catch a 401
and redirect to the login page globally without touching individual API calls.
This pattern is similar to Axios but built entirely on native fetch
, giving you fine-grained control without external dependencies.
export const FetchInterceptor = (props: { children: React.JSX.Element }) => {
const { idToken } = useOidcIdToken();
const { login } = useOidc();
// Request interceptor for API calls
FetchClient.interceptors.request.use((config) => {
// Attach the bearer token to the request header if available
if (idToken) {
config.headers = {
...config.headers,
Authorization: `Bearer ${idToken}`,
};
}
// Set default headers
config.headers = {
'Content-Type': 'application/json',
...config.headers,
};
return config;
});
// Response interceptor for API calls
FetchClient.interceptors.response.use(
(response: FetchResponse) => response,
(error: FetchError) => {
const statusCode = error.statusCode;
// Handle 401 Unauthorized - redirect to login
if (statusCode === HTTP_401) {
login().then((r) => r);
return;
}
// Handle other errors asynchronously
(async () => {
// Handle 403 Forbidden
if (statusCode === HTTP_403) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_403);
const formattedError = createFormattedError(error, HTTP_403, message, error.response);
console.error('Access Forbidden:', message);
return Promise.reject(formattedError);
}
// Handle 404 Not Found
if (statusCode === HTTP_404) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_404);
const formattedError = createFormattedError(error, HTTP_404, message, error.response);
console.error('Resource Not Found:', message);
return Promise.reject(formattedError);
}
// Handle 405 Method Not Allowed
if (statusCode === HTTP_405) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_405);
const formattedError = createFormattedError(error, HTTP_405, message, error.response);
return Promise.reject(formattedError);
}
// Handle 409 Conflict
if (statusCode === HTTP_409) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_409);
const formattedError = createFormattedError(error, HTTP_409, message, error.response);
return Promise.reject(formattedError);
}
// Handle 5xx Server Errors
if (statusCode && statusCode >= 500 && statusCode <= 599) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(statusCode);
const formattedError = createFormattedError(error, statusCode, message, error.response);
console.error(`Server Error (${statusCode}):`, message);
return Promise.reject(formattedError);
}
// Handle network errors (no response)
if (!error.response) {
const networkError = createFormattedError(error, 0, 'Network error. Please check your connection and try again.');
console.error('Network Error:', error.message);
return Promise.reject(networkError);
}
// Handle any other errors
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || error.message || getDefaultErrorMessage(statusCode || 0);
const formattedError = createFormattedError(error, statusCode || 0, message, error.response);
console.error(`HTTP Error (${statusCode}):`, message);
return Promise.reject(formattedError);
})();
}
);
return props.children;
};
4. Building URLs with Params
The client includes a utility that takes a base URL, an endpoint, and optional query parameters to build the full request URL. This eliminates repetitive string concatenation and manual query string encoding.
It ensures all query params are properly URL-encoded, which is especially important when dealing with special characters or spaces in parameter values. The function also works seamlessly for both absolute and relative URLs.
By centralizing this logic, you also reduce the risk of inconsistent parameter handling across different parts of the app.
const buildURL = (baseURL: string, url: string, params?: Record<string, any>): string => {
const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`;
if (!params) return fullURL;
const urlObj = new URL(fullURL);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlObj.searchParams.append(key, String(value));
}
});
return urlObj.toString();
};
5. Unified Response Parsing
The client automatically determines how to parse the response based on its Content-Type
. JSON, plain text, and binary data (blobs) are all supported out of the box.
This avoids having to write repetitive response.json()
or response.text()
calls in every API request. It also ensures that any new response types your API introduces can be handled by updating a single function.
With this approach, your component logic receives already-parsed data, making it cleaner and more maintainable.
const parseResponseData = async (response: Response): Promise<any> => {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (contentType?.includes('text/')) {
return await response.text();
} else {
return await response.blob();
}
};
6. Error Handling
Every error is wrapped into a FetchError
object containing the status code, the original error, and optionally the response and request.
This makes error handling predictable — components can check error.statusCode
rather than manually parsing error messages. The client also uses interceptors to globally log, transform, or redirect based on specific errors.
In case of network failures, a friendly “Network error” message is returned, preventing cryptic browser errors from reaching the user. We wrap errors in a consistent FetchError
object with:
statusCode
originalError
response
request
Auth Integration Example
In this example we are using @axa-fr/react-oidc
, we inject the ID token into every request and redirect on 401
. But you can use any library you like
export const FetchInterceptor = ({ children }) => {
const { idToken } = useOidcIdToken();
const { login } = useOidc();
FetchClient.interceptors.request.use((config) => {
if (idToken) config.headers = { ...config.headers, Authorization: `Bearer ${idToken}` };
return config;
});
FetchClient.interceptors.response.use(null, (error) => {
if (error.statusCode === 401) login();
});
return children;
};
Usage
Using the client is straightforward — you just call its helper methods for each HTTP verb. The client handles URL building, parsing, and error handling automatically.
The result object always contains data
, status
, statusText
, and headers
, making it easy to work with in your UI. This approach greatly reduces repetitive fetch
boilerplate in your components.
Here’s a quick example:
// Simple GET
const res = await FetchClient.get('/users');
console.log(res.data);
// POST with body
await FetchClient.post('/users', { name: 'John' });
Benefits
✅ Centralized API logic – Changes happen in one place.
✅ Consistent error handling – No more scattered try/catch
logic.
✅ Seamless authentication integration – Tokens auto-attach, login auto-triggers.
✅ No repeated boilerplate – Cleaner, smaller components.
With this reusable fetch client, your app gains a robust, maintainable HTTP layer without the weight of external dependencies.
Complete Code
import { useOidc, useOidcIdToken } from '@axa-fr/react-oidc';
import React from 'react';
import { HTTP_401, HTTP_403, HTTP_404, HTTP_405, HTTP_409, HTTP_500, HTTP_501, HTTP_502, HTTP_503, HTTP_504, HTTP_505 } from '../constants/HttpConstants';
/**
* Interface for error response structure
*/
interface ErrorResponse {
message: string;
errorCode: string | null;
status: string;
statusCode: number;
timestamp: string;
errors: any[] | null;
path: string | null;
}
/**
* Interface for request configuration
*/
interface RequestConfig extends RequestInit {
baseURL?: string;
url?: string;
params?: Record<string, any>;
}
/**
* Interface for response structure
*/
interface FetchResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
config: RequestConfig;
}
/**
* Interface for error structure
*/
interface FetchError extends Error {
statusCode: number;
originalError: Error;
response?: Response;
request?: Request;
}
/**
* Get default error message based on HTTP status code
* @param statusCode HTTP status code
* @returns Default error message
*/
const getDefaultErrorMessage = (statusCode: number): string => {
switch (statusCode) {
case HTTP_401:
return 'Authentication required. Please log in again.';
case HTTP_403:
return 'Access denied. You do not have permission to access this resource.';
case HTTP_404:
return 'The requested resource was not found.';
case HTTP_405:
return 'Method not allowed. This operation is not supported.';
case HTTP_409:
return 'Conflict. The request conflicts with the current state of the resource.';
case HTTP_500:
return 'Internal server error. Please try again later.';
case HTTP_501:
return 'Not implemented. This feature is not available.';
case HTTP_502:
return 'Bad gateway. The server received an invalid response.';
case HTTP_503:
return 'Service unavailable. The server is temporarily unavailable.';
case HTTP_504:
return 'Gateway timeout. The request timed out.';
case HTTP_505:
return 'HTTP version not supported.';
default:
return 'An unexpected error occurred.';
}
};
/**
* Creates a formatted error object with additional context
* @param error The original error
* @param statusCode HTTP status code
* @param message Error message
* @param response Response object
* @param request Request object
* @returns Formatted error object
*/
const createFormattedError = (error: Error, statusCode: number, message: string, response?: Response, request?: Request): FetchError => {
const formattedError = new Error(message) as FetchError;
formattedError.statusCode = statusCode;
formattedError.originalError = error;
formattedError.response = response;
formattedError.request = request;
return formattedError;
};
/**
* Build URL with query parameters
* @param baseURL Base URL
* @param url Endpoint URL
* @param params Query parameters
* @returns Complete URL with query parameters
*/
const buildURL = (baseURL: string, url: string, params?: Record<string, any>): string => {
const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`;
if (!params) return fullURL;
const urlObj = new URL(fullURL);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlObj.searchParams.append(key, String(value));
}
});
return urlObj.toString();
};
/**
* Parse response data based on content type
* @param response Fetch response
* @returns Parsed data
*/
const parseResponseData = async (response: Response): Promise<any> => {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (contentType?.includes('text/')) {
return await response.text();
} else {
return await response.blob();
}
};
/**
* Reusable fetch client class
*
* @author Pavan Kumar Jadda
* @since 0.2.19
*/
class FetchInstance {
private baseURL: string;
private requestInterceptors: Array<(config: RequestConfig) => RequestConfig | Promise<RequestConfig>> = [];
private responseInterceptors: Array<(response: FetchResponse) => FetchResponse | Promise<FetchResponse>> = [];
private errorInterceptors: Array<(error: FetchError) => FetchError | Promise<FetchError> | void | Promise<void>> = [];
constructor(baseURL: string = import.meta.env.VITE_REACT_APP_BASE_URL) {
this.baseURL = baseURL;
}
/**
* Add request interceptor
*/
interceptors = {
request: {
use: (interceptor: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>) => {
this.requestInterceptors.push(interceptor);
},
},
response: {
use: (
successInterceptor?: (response: FetchResponse) => FetchResponse | Promise<FetchResponse>,
errorInterceptor?: (error: FetchError) => FetchError | Promise<FetchError> | void | Promise<void>
) => {
if (successInterceptor) {
this.responseInterceptors.push(successInterceptor);
}
if (errorInterceptor) {
this.errorInterceptors.push(errorInterceptor);
}
},
},
};
/**
* Execute request with interceptors
*/
private async executeRequest(config: RequestConfig): Promise<FetchResponse> {
// Apply request interceptors
let processedConfig = config;
for (const interceptor of this.requestInterceptors) {
processedConfig = await interceptor(processedConfig);
}
const { url, params, ...fetchConfig } = processedConfig;
const fullURL = buildURL(this.baseURL, url || '', params);
try {
const response = await fetch(fullURL, fetchConfig);
const data = await parseResponseData(response);
const fetchResponse: FetchResponse = {
data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: processedConfig,
};
// Apply response interceptors
let processedResponse = fetchResponse;
for (const interceptor of this.responseInterceptors) {
processedResponse = await interceptor(processedResponse);
}
return processedResponse;
} catch (error) {
const fetchError = createFormattedError(error as Error, 0, 'Network error. Please check your connection and try again.');
// Apply error interceptors
let processedError = fetchError;
for (const interceptor of this.errorInterceptors) {
const result = await interceptor(processedError);
if (result !== undefined) {
processedError = result;
}
}
throw processedError;
}
}
/**
* GET request
*/
async get<T = any>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<FetchResponse<T>> {
return this.executeRequest({ ...config, method: 'GET', url });
}
/**
* POST request
*/
async post<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'POST', url, body });
}
/**
* PUT request
*/
async put<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'PUT', url, body });
}
/**
* DELETE request
*/
async delete<T = any>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<FetchResponse<T>> {
return this.executeRequest({ ...config, method: 'DELETE', url });
}
/**
* PATCH request
*/
async patch<T = any>(url: string, data?: any, config?: Omit<RequestConfig, 'method' | 'url' | 'body'>): Promise<FetchResponse<T>> {
const body = data ? JSON.stringify(data) : undefined;
return this.executeRequest({ ...config, method: 'PATCH', url, body });
}
/**
* Generic request method
*/
async request<T = any>(config: RequestConfig): Promise<FetchResponse<T>> {
return this.executeRequest(config);
}
}
/**
* Adds error handling interceptor
*
* @author Pavan Kumar Jadda
* @since 1.1.1
*/
export const FetchInterceptor = (props: { children: React.JSX.Element }) => {
const { idToken } = useOidcIdToken();
const { login } = useOidc();
// Request interceptor for API calls
FetchClient.interceptors.request.use((config) => {
// Attach the bearer token to the request header if available
if (idToken) {
config.headers = {
...config.headers,
Authorization: `Bearer ${idToken}`,
};
}
// Set default headers
config.headers = {
'Content-Type': 'application/json',
...config.headers,
};
return config;
});
// Response interceptor for API calls
FetchClient.interceptors.response.use(
(response: FetchResponse) => response,
(error: FetchError) => {
const statusCode = error.statusCode;
// Handle 401 Unauthorized - redirect to login
if (statusCode === HTTP_401) {
login().then((r) => r);
return;
}
// Handle other errors asynchronously
(async () => {
// Handle 403 Forbidden
if (statusCode === HTTP_403) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_403);
const formattedError = createFormattedError(error, HTTP_403, message, error.response);
console.error('Access Forbidden:', message);
return Promise.reject(formattedError);
}
// Handle 404 Not Found
if (statusCode === HTTP_404) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_404);
const formattedError = createFormattedError(error, HTTP_404, message, error.response);
console.error('Resource Not Found:', message);
return Promise.reject(formattedError);
}
// Handle 405 Method Not Allowed
if (statusCode === HTTP_405) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_405);
const formattedError = createFormattedError(error, HTTP_405, message, error.response);
return Promise.reject(formattedError);
}
// Handle 409 Conflict
if (statusCode === HTTP_409) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(HTTP_409);
const formattedError = createFormattedError(error, HTTP_409, message, error.response);
return Promise.reject(formattedError);
}
// Handle 5xx Server Errors
if (statusCode && statusCode >= 500 && statusCode <= 599) {
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || getDefaultErrorMessage(statusCode);
const formattedError = createFormattedError(error, statusCode, message, error.response);
console.error(`Server Error (${statusCode}):`, message);
return Promise.reject(formattedError);
}
// Handle network errors (no response)
if (!error.response) {
const networkError = createFormattedError(error, 0, 'Network error. Please check your connection and try again.');
console.error('Network Error:', error.message);
return Promise.reject(networkError);
}
// Handle any other errors
const errorResponse = error.response ? ((await parseResponseData(error.response)) as ErrorResponse) : null;
const message = errorResponse?.message || error.message || getDefaultErrorMessage(statusCode || 0);
const formattedError = createFormattedError(error, statusCode || 0, message, error.response);
console.error(`HTTP Error (${statusCode}):`, message);
return Promise.reject(formattedError);
})();
}
);
return props.children;
};
// Remove the callable FetchClient export and only export the instance
const FetchClient = new FetchInstance();
// Export the client instance for direct use
export default FetchClient;