Building a Production-Ready API Client in React
Building a basic API client with Fetch is straightforward. Building one that handles the complexities of production—authentication token refresh, retry logic, rate limiting, request cancellation, and proper error recovery—requires careful architecture. A well-designed API client becomes the backbone of your application, isolating networking concerns from your components and making your codebase testable and maintainable.
This guide covers the patterns and techniques that separate hobby projects from enterprise React applications. We'll build a complete, reusable API client that you can adapt to your project.
Table of Contents
- Architecture Overview
- Core API Client Structure
- Request and Response Interceptors
- Error Handling and Recovery
- Retry Mechanisms
- Request Cancellation
- Caching Strategies
- TypeScript Type Safety
- Integration with React Components
- Testing Your API Client
- FAQ
Architecture Overview
A production API client separates concerns into distinct layers:
Transport Layer: The lowest level handles raw HTTP requests using Fetch API. This is where you configure headers, manage authentication, and handle network communication.
Middleware Layer: This intercepts requests and responses. Here you add headers, handle token refresh, log requests, and transform data. Think of it as the "before" and "after" hooks for every request.
Business Logic Layer: This is where your specific API endpoints live. It uses the transport layer and applies domain-specific logic like pagination, filtering, and data normalization.
Error Handling Layer: Consistent error handling across all requests. Different error types get different treatment—4xx errors differ from 5xx errors, which differ from network failures.
A well-structured API client lets you change the HTTP library (from Fetch to axios) without touching your React components. It makes adding authentication, logging, or retry logic a one-time task instead of repetitive boilerplate.
Core API Client Structure
Here's the foundation of a production API client:
TypeScript Version
// types.ts
export interface ApiConfig {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
}
export interface ApiError {
status: number;
message: string;
data?: unknown;
}
export interface ApiResponse<T = unknown> {
data: T;
status: number;
headers: Headers;
}
// api-client.ts
export class ApiClient {
private baseURL: string;
private timeout: number;
private defaultHeaders: Record<string, string>;
private requestInterceptors: ((config: RequestConfig) => RequestConfig)[] = [];
private responseInterceptors: ((response: Response) => Promise<Response> | Response)[] = [];
constructor(config: ApiConfig) {
this.baseURL = config.baseURL;
this.timeout = config.timeout || 30000;
this.defaultHeaders = {
'Content-Type': 'application/json',
...config.headers,
};
}
// Add request interceptor
addRequestInterceptor(interceptor: (config: RequestConfig) => RequestConfig) {
this.requestInterceptors.push(interceptor);
}
// Add response interceptor
addResponseInterceptor(interceptor: (response: Response) => Promise<Response> | Response) {
this.responseInterceptors.push(interceptor);
}
// Main request method
async request<T = unknown>(
method: string,
path: string,
options: RequestOptions = {}
): Promise<ApiResponse<T>> {
let config: RequestConfig = {
method,
url: `${this.baseURL}${path}`,
headers: { ...this.defaultHeaders, ...options.headers },
body: options.body,
signal: options.signal,
};
// Apply request interceptors
for (const interceptor of this.requestInterceptors) {
config = interceptor(config);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
let response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body ? JSON.stringify(config.body) : undefined,
signal: config.signal || controller.signal,
});
// Apply response interceptors
for (const interceptor of this.responseInterceptors) {
response = await interceptor(response);
}
clearTimeout(timeoutId);
if (!response.ok) {
throw new ApiClientError(
response.status,
`HTTP ${response.status}: ${response.statusText}`,
response
);
}
const data = await response.json() as T;
return {
data,
status: response.status,
headers: response.headers,
};
} catch (error) {
clearTimeout(timeoutId);
throw this.handleError(error);
}
}
// Convenience methods
get<T>(path: string, options?: RequestOptions) {
return this.request<T>('GET', path, options);
}
post<T>(path: string, body?: unknown, options?: RequestOptions) {
return this.request<T>('POST', path, { ...options, body });
}
put<T>(path: string, body?: unknown, options?: RequestOptions) {
return this.request<T>('PUT', path, { ...options, body });
}
delete<T>(path: string, options?: RequestOptions) {
return this.request<T>('DELETE', path, options);
}
private handleError(error: unknown): ApiClientError {
if (error instanceof ApiClientError) {
return error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
return new ApiClientError(0, 'Request timeout or cancelled', null);
}
return new ApiClientError(0, error.message, null);
}
return new ApiClientError(0, 'Unknown error occurred', null);
}
}
// error.ts
export class ApiClientError extends Error {
constructor(
public status: number,
message: string,
public response: Response | null
) {
super(message);
this.name = 'ApiClientError';
}
isNetworkError(): boolean {
return this.status === 0;
}
isClientError(): boolean {
return this.status >= 400 && this.status < 500;
}
isServerError(): boolean {
return this.status >= 500;
}
isTimeout(): boolean {
return this.message.includes('timeout');
}
}
interface RequestConfig {
method: string;
url: string;
headers: Record<string, string>;
body?: unknown;
signal?: AbortSignal;
}
interface RequestOptions {
headers?: Record<string, string>;
body?: unknown;
signal?: AbortSignal;
}
JavaScript Version
// api-client.js
export class ApiClient {
constructor(config) {
this.baseURL = config.baseURL;
this.timeout = config.timeout || 30000;
this.defaultHeaders = {
'Content-Type': 'application/json',
...config.headers,
};
this.requestInterceptors = [];
this.responseInterceptors = [];
}
addRequestInterceptor(interceptor) {
this.requestInterceptors.push(interceptor);
}
addResponseInterceptor(interceptor) {
this.responseInterceptors.push(interceptor);
}
async request(method, path, options = {}) {
let config = {
method,
url: `${this.baseURL}${path}`,
headers: { ...this.defaultHeaders, ...options.headers },
body: options.body,
signal: options.signal,
};
// Apply request interceptors
for (const interceptor of this.requestInterceptors) {
config = interceptor(config);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
let response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body ? JSON.stringify(config.body) : undefined,
signal: config.signal || controller.signal,
});
// Apply response interceptors
for (const interceptor of this.responseInterceptors) {
response = await interceptor(response);
}
clearTimeout(timeoutId);
if (!response.ok) {
throw new ApiClientError(
response.status,
`HTTP ${response.status}: ${response.statusText}`,
response
);
}
const data = await response.json();
return { data, status: response.status, headers: response.headers };
} catch (error) {
clearTimeout(timeoutId);
throw this.handleError(error);
}
}
get(path, options) {
return this.request('GET', path, options);
}
post(path, body, options) {
return this.request('POST', path, { ...options, body });
}
put(path, body, options) {
return this.request('PUT', path, { ...options, body });
}
delete(path, options) {
return this.request('DELETE', path, options);
}
handleError(error) {
if (error instanceof ApiClientError) {
return error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
return new ApiClientError(0, 'Request timeout or cancelled', null);
}
return new ApiClientError(0, error.message, null);
}
return new ApiClientError(0, 'Unknown error occurred', null);
}
}
export class ApiClientError extends Error {
constructor(status, message, response) {
super(message);
this.name = 'ApiClientError';
this.status = status;
this.response = response;
}
isNetworkError() {
return this.status === 0;
}
isClientError() {
return this.status >= 400 && this.status < 500;
}
isServerError() {
return this.status >= 500;
}
isTimeout() {
return this.message.includes('timeout');
}
}
Request and Response Interceptors
Interceptors transform requests before they're sent and responses before they're returned. This is where authentication headers, logging, and global error handling happen.
TypeScript Version
// Create API client instance
export const apiClient = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'https://api.example.com',
timeout: 30000,
});
// Authentication interceptor - add token to requests
apiClient.addRequestInterceptor((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// Logging interceptor
apiClient.addRequestInterceptor((config) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[API] ${config.method} ${config.url}`);
}
return config;
});
// Response logging interceptor
apiClient.addResponseInterceptor(async (response) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[API] Response ${response.status} for ${response.url}`);
}
return response;
});
// Handle 401 - Token expired
apiClient.addResponseInterceptor(async (response) => {
if (response.status === 401) {
// Attempt to refresh token
const refreshed = await refreshAuthToken();
if (refreshed) {
// Retry the original request with new token
return response;
}
}
return response;
});
JavaScript Version
export const apiClient = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'https://api.example.com',
timeout: 30000,
});
// Authentication interceptor
apiClient.addRequestInterceptor((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// Logging interceptor
apiClient.addRequestInterceptor((config) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[API] ${config.method} ${config.url}`);
}
return config;
});
// Response logging
apiClient.addResponseInterceptor(async (response) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[API] Response ${response.status} for ${response.url}`);
}
return response;
});
Error Handling and Recovery
Production applications need graceful error handling. Different error types require different strategies.
TypeScript Version
// error-handler.ts
export class ErrorHandler {
static handle(error: ApiClientError): ErrorResponse {
if (error.isNetworkError()) {
return {
type: 'network',
message: 'Network error. Please check your connection.',
recoverable: true,
};
}
if (error.isTimeout()) {
return {
type: 'timeout',
message: 'Request timed out. Please try again.',
recoverable: true,
};
}
if (error.status === 401) {
return {
type: 'unauthorized',
message: 'Your session has expired. Please log in again.',
recoverable: false,
};
}
if (error.status === 403) {
return {
type: 'forbidden',
message: 'You do not have permission to access this resource.',
recoverable: false,
};
}
if (error.status === 404) {
return {
type: 'not_found',
message: 'The requested resource was not found.',
recoverable: false,
};
}
if (error.isClientError()) {
return {
type: 'client_error',
message: error.message,
recoverable: false,
};
}
if (error.isServerError()) {
return {
type: 'server_error',
message: 'Server error. Please try again later.',
recoverable: true,
};
}
return {
type: 'unknown',
message: 'An unexpected error occurred.',
recoverable: false,
};
}
}
interface ErrorResponse {
type: string;
message: string;
recoverable: boolean;
}
JavaScript Version
export class ErrorHandler {
static handle(error) {
if (error.isNetworkError()) {
return {
type: 'network',
message: 'Network error. Please check your connection.',
recoverable: true,
};
}
if (error.isTimeout()) {
return {
type: 'timeout',
message: 'Request timed out. Please try again.',
recoverable: true,
};
}
if (error.status === 401) {
return {
type: 'unauthorized',
message: 'Your session has expired. Please log in again.',
recoverable: false,
};
}
if (error.isServerError()) {
return {
type: 'server_error',
message: 'Server error. Please try again later.',
recoverable: true,
};
}
return {
type: 'unknown',
message: 'An unexpected error occurred.',
recoverable: false,
};
}
}
Retry Mechanisms
Transient failures (network hiccups, temporary server issues) should be retried. Permanent failures (404, 401) should not.
TypeScript Version
export interface RetryConfig {
maxRetries: number;
initialDelay: number;
maxDelay: number;
backoffMultiplier: number;
}
export class RetryPolicy {
constructor(private config: RetryConfig) {}
async executeWithRetry<T>(
fn: () => Promise<T>,
isRetryable: (error: ApiClientError) => boolean
): Promise<T> {
let lastError: ApiClientError | null = null;
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof ApiClientError) {
lastError = error;
// Don't retry if error isn't retryable
if (!isRetryable(error)) {
throw error;
}
// Don't retry on last attempt
if (attempt < this.config.maxRetries) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
}
} else {
throw error;
}
}
}
throw lastError;
}
private calculateDelay(attempt: number): number {
const exponentialDelay = this.config.initialDelay *
Math.pow(this.config.backoffMultiplier, attempt);
return Math.min(exponentialDelay, this.config.maxDelay);
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage in API client
const retryPolicy = new RetryPolicy({
maxRetries: 3,
initialDelay: 100,
maxDelay: 5000,
backoffMultiplier: 2,
});
async function fetchDataWithRetry<T>(path: string) {
return retryPolicy.executeWithRetry(
() => apiClient.get<T>(path),
(error) => {
// Retry on network errors and 5xx errors
return error.isNetworkError() || error.isServerError();
}
);
}
JavaScript Version
export class RetryPolicy {
constructor(config) {
this.config = config;
}
async executeWithRetry(fn, isRetryable) {
let lastError = null;
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof ApiClientError) {
lastError = error;
if (!isRetryable(error)) {
throw error;
}
if (attempt < this.config.maxRetries) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
}
} else {
throw error;
}
}
}
throw lastError;
}
calculateDelay(attempt) {
const exponentialDelay = this.config.initialDelay *
Math.pow(this.config.backoffMultiplier, attempt);
return Math.min(exponentialDelay, this.config.maxDelay);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Request Cancellation
When components unmount or users navigate away, cancel in-flight requests to prevent memory leaks.
TypeScript Version
export class RequestManager {
private controllers = new Map<string, AbortController>();
createController(requestId: string): AbortSignal {
const controller = new AbortController();
this.controllers.set(requestId, controller);
return controller.signal;
}
cancel(requestId: string): void {
const controller = this.controllers.get(requestId);
if (controller) {
controller.abort();
this.controllers.delete(requestId);
}
}
cancelAll(): void {
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
}
}
// Usage in React hook
export function useFetchData<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiClientError | null>(null);
const requestManager = useRef(new RequestManager());
const requestId = useRef(Math.random().toString());
useEffect(() => {
const fetchData = async () => {
try {
const signal = requestManager.current.createController(requestId.current);
const response = await apiClient.get<T>(url, { signal });
setData(response.data);
setError(null);
} catch (err) {
if (err instanceof ApiClientError && !err.message.includes('cancelled')) {
setError(err);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => {
requestManager.current.cancel(requestId.current);
};
}, [url]);
return { data, loading, error };
}
JavaScript Version
export class RequestManager {
constructor() {
this.controllers = new Map();
}
createController(requestId) {
const controller = new AbortController();
this.controllers.set(requestId, controller);
return controller.signal;
}
cancel(requestId) {
const controller = this.controllers.get(requestId);
if (controller) {
controller.abort();
this.controllers.delete(requestId);
}
}
cancelAll() {
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
}
}
Caching Strategies
Smart caching reduces API load and improves performance. Implement a simple in-memory cache with TTL (time-to-live).
TypeScript Version
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
export class CacheManager {
private cache = new Map<string, CacheEntry<unknown>>();
set<T>(key: string, data: T, ttlSeconds: number = 300): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: ttlSeconds * 1000,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
return null;
}
// Check if cache has expired
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
invalidate(key: string): void {
this.cache.delete(key);
}
invalidatePattern(pattern: RegExp): void {
for (const key of this.cache.keys()) {
if (pattern.test(key)) {
this.cache.delete(key);
}
}
}
clear(): void {
this.cache.clear();
}
}
// Usage
const cacheManager = new CacheManager();
export async function fetchUserData(userId: string) {
const cacheKey = `user:${userId}`;
// Check cache first
const cached = cacheManager.get(cacheKey);
if (cached) {
return cached;
}
// Fetch from API
const response = await apiClient.get(`/users/${userId}`);
// Cache for 5 minutes
cacheManager.set(cacheKey, response.data, 300);
return response.data;
}
JavaScript Version
export class CacheManager {
constructor() {
this.cache = new Map();
}
set(key, data, ttlSeconds = 300) {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: ttlSeconds * 1000,
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
invalidate(key) {
this.cache.delete(key);
}
invalidatePattern(pattern) {
for (const key of this.cache.keys()) {
if (pattern.test(key)) {
this.cache.delete(key);
}
}
}
clear() {
this.cache.clear();
}
}
TypeScript Type Safety
Strong typing prevents runtime errors. Create specific types for your API responses.
// Discriminated union for API responses
export type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: ApiClientError };
// Generic paginated response
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}
// Type guard for successful responses
export function isSuccess<T>(
response: ApiResponse<T>
): response is { status: 'success'; data: T } {
return response.status === 'success';
}
// Usage in component
async function fetchUsers(page: number): Promise<PaginatedResponse<User>> {
const response = await apiClient.get<PaginatedResponse<User>>(`/users?page=${page}`);
return response.data;
}
Integration with React Components
Create custom hooks that leverage your API client.
TypeScript Version
export function useFetch<T>(
url: string,
options?: { skip?: boolean; dependencies?: unknown[] }
) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: ApiClientError | null;
}>({
data: null,
loading: !options?.skip,
error: null,
});
useEffect(() => {
if (options?.skip) return;
let isMounted = true;
const requestId = Math.random().toString();
const fetchData = async () => {
try {
const signal = new AbortController().signal;
const response = await apiClient.get<T>(url, { signal });
if (isMounted) {
setState({ data: response.data, loading: false, error: null });
}
} catch (err) {
if (isMounted && err instanceof ApiClientError) {
setState({ data: null, loading: false, error: err });
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, options?.dependencies || [url]);
return state;
}
// Usage in component
export function UsersList() {
const { data: users, loading, error } = useFetch<User[]>('/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
JavaScript Version
export function useFetch(url, options) {
const [state, setState] = useState({
data: null,
loading: !options?.skip,
error: null,
});
useEffect(() => {
if (options?.skip) return;
let isMounted = true;
const fetchData = async () => {
try {
const signal = new AbortController().signal;
const response = await apiClient.get(url, { signal });
if (isMounted) {
setState({ data: response.data, loading: false, error: null });
}
} catch (err) {
if (isMounted && err instanceof ApiClientError) {
setState({ data: null, loading: false, error: err });
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, options?.dependencies || [url]);
return state;
}
Testing Your API Client
Production code requires tests. Here's how to mock your API client.
// __tests__/api-client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ApiClient, ApiClientError } from '../api-client';
describe('ApiClient', () => {
let client: ApiClient;
beforeEach(() => {
client = new ApiClient({ baseURL: 'https://api.example.com' });
// Mock fetch globally
global.fetch = vi.fn();
});
it('should make GET request', async () => {
const mockData = { id: 1, name: 'Test' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockData,
headers: new Headers(),
});
const response = await client.get('/test');
expect(response.data).toEqual(mockData);
});
it('should throw ApiClientError on 404', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
headers: new Headers(),
});
await expect(client.get('/nonexistent')).rejects.toThrow(ApiClientError);
});
it('should add request interceptors', async () => {
const interceptor = vi.fn((config) => config);
client.addRequestInterceptor(interceptor);
(global.fetch as any).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
headers: new Headers(),
});
await client.get('/test');
expect(interceptor).toHaveBeenCalled();
});
});
Practical Real-World Example
Consider a content platform like those used at ByteDance: you need to handle authentication, pagination, search, and cache invalidation when users create content.
TypeScript Version
// services/content-api.ts
export class ContentAPI {
constructor(private api: ApiClient, private cache: CacheManager) {}
async searchContent(
query: string,
page: number = 1,
pageSize: number = 20
): Promise<PaginatedResponse<Content>> {
const cacheKey = `search:${query}:${page}`;
const cached = this.cache.get<PaginatedResponse<Content>>(cacheKey);
if (cached) return cached;
const response = await this.api.get<PaginatedResponse<Content>>(
`/content/search?q=${query}&page=${page}&pageSize=${pageSize}`
);
// Cache search results for 10 minutes
this.cache.set(cacheKey, response.data, 600);
return response.data;
}
async createContent(data: CreateContentInput): Promise<Content> {
const response = await this.api.post<Content>('/content', data);
// Invalidate search cache after creating content
this.cache.invalidatePattern(/^search:/);
return response.data;
}
async getUserContent(userId: string): Promise<Content[]> {
const cacheKey = `user:${userId}:content`;
const cached = this.cache.get<Content[]>(cacheKey);
if (cached) return cached;
const response = await this.api.get<Content[]>(`/users/${userId}/content`);
this.cache.set(cacheKey, response.data, 300);
return response.data;
}
}
// Hook usage
export function useContentSearch(query: string) {
const contentAPI = useContext(ContentAPIContext);
const [state, setState] = useState<{
results: PaginatedResponse<Content> | null;
loading: boolean;
error: ApiClientError | null;
}>({ results: null, loading: true, error: null });
useEffect(() => {
if (!query.trim()) {
setState({ results: null, loading: false, error: null });
return;
}
let isMounted = true;
const search = async () => {
try {
const results = await contentAPI.searchContent(query);
if (isMounted) {
setState({ results, loading: false, error: null });
}
} catch (error) {
if (isMounted && error instanceof ApiClientError) {
setState({ results: null, loading: false, error });
}
}
};
// Debounce search
const timeout = setTimeout(search, 300);
return () => clearTimeout(timeout);
}, [query, contentAPI]);
return state;
}
JavaScript Version
export class ContentAPI {
constructor(api, cache) {
this.api = api;
this.cache = cache;
}
async searchContent(query, page = 1, pageSize = 20) {
const cacheKey = `search:${query}:${page}`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const response = await this.api.get(
`/content/search?q=${query}&page=${page}&pageSize=${pageSize}`
);
this.cache.set(cacheKey, response.data, 600);
return response.data;
}
async createContent(data) {
const response = await this.api.post('/content', data);
this.cache.invalidatePattern(/^search:/);
return response.data;
}
async getUserContent(userId) {
const cacheKey = `user:${userId}:content`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const response = await this.api.get(`/users/${userId}/content`);
this.cache.set(cacheKey, response.data, 300);
return response.data;
}
}
FAQ
Q: Should I build a custom API client or use a library like axios or React Query?
A: Start with a custom client if you need specific control or have unique requirements. Use axios for simpler projects where you just need convenience methods. Use React Query for complex data fetching with built-in caching and synchronization. For production apps, React Query handles many edge cases you'd otherwise build yourself.
Q: How do I handle token refresh without making users log in again?
A: Implement a response interceptor that detects 401 responses and attempts silent refresh. If refresh succeeds, retry the original request. If refresh fails, redirect to login. The key is queueing pending requests during the refresh so they retry with the new token.
Q: What's the difference between request timeout and request cancellation?
A: Timeout means the request takes too long (defined by you). Cancellation means you explicitly told it to stop (user navigated away, component unmounted). Both use AbortController. Timeouts are automatic; cancellation is manual.
Q: How should I structure error handling for different API endpoints?
A: Create a base API class with standard error handling. Create service classes that inherit or wrap it for specific domains (UserAPI, ContentAPI). Each service can override error handling for domain-specific logic. This keeps concerns separated.
Q: Should I cache all GET requests or be selective?
A: Be selective. Cache data that's expensive or changes infrequently. Don't cache user-specific data or sensitive information. Use short TTLs for volatile data, longer TTLs for stable data. Invalidate caches when related data is created/updated.
Ready to build a robust API client for your React app? Share your specific requirements in the comments—whether you're dealing with complex authentication flows, handling massive datasets, or optimizing for high-frequency APIs, the community can help you design the right architecture.
Google AdSense Placeholder
CONTENT SLOT