Introduction
Let's be honest—we've all been there. You start a new project with the best intentions: clean code, proper error handling, consistent API patterns. Fast-forward six months, and your codebase looks like a museum of different approaches to API calls. Some components use fetch directly, others have custom hooks, and that one file from the intern still has Axios calls scattered everywhere.
Sound familiar? You're not alone.
Modern frontend applications are essentially API orchestrators. Whether you're building a React dashboard, a Vue e-commerce site, or a Svelte analytics tool, the way you handle API communication can make or break your application's maintainability, performance, and developer experience.
Today, we'll explore battle-tested patterns that solve real problems—from basic request/response handling to complex state management scenarios. These aren't theoretical concepts; they're patterns that have survived production traffic, evolved through code reviews, and made developers' lives easier.
The Foundation: Understanding Your Communication Needs
Before diving into patterns, let's establish what good API communication looks like in modern frontend applications. We need solutions that handle:
- Consistent error handling across all API calls
- Loading states that don't create hanky user experiences
- Caching strategies that balance freshness with performance
- Request cancellation to prevent race conditions
- Type safety that catches bugs before they reach production
- Retry logic for transient failures
Pattern 1: The API Client Pattern
The foundation of clean API communication starts with centralizing your HTTP logic. Instead of scattering fetch calls throughout your components, create a dedicated API client.
// api/client.ts
class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string) {
this.baseURL = baseURL;
this.defaultHeaders = {
'Content-Type': 'application/json',
};
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: { ...this.defaultHeaders, ...options.headers },
...options,
};
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData,
response
);
}
return response.json();
}
async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
}
// Usage
const apiClient = new ApiClient('https://api.example.com');
const users = await apiClient.get<User[]>('/users');
This pattern gives you a single place to handle authentication, request/response transformations, and global error handling. Your components stay clean, and your API logic stays consistent.
Note: This example throws on any non-2xx status for simplicity. In production applications, you might want to handle specific status codes differently (e.g., 404 for 'not found' scenarios) or provide access to the full response object for more nuanced error handling.
Pattern 2: The Custom Hook Pattern (React)
For React applications, custom hooks provide an elegant way to encapsulate API logic while leveraging React's built-in state management.
// hooks/useApi.ts
function useApi<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const execute = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await fetcher();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [fetcher]);
return { data, loading, error, execute };
}
// Component usage
function UserList() {
const { data: users, loading, error, execute } = useApi(
() => apiClient.get<User[]>('/users')
);
useEffect(() => {
execute();
}, [execute]);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
return (
<div>
{users?.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Pattern 3: The Query Client Pattern
For applications with complex data requirements, a dedicated query client like React Query (TanStack Query) or SWR provides advanced caching, synchronisation, and background updates.
// Using React Query
import {
useQuery,
useMutation,
useQueryClient
} from '@tanstack/react-query';
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => apiClient.get<User[]>('/users'),
staleTime: 5 * 60 * 1000,// 5 minutes
cacheTime: 10 * 60 * 1000,// 10 minutes
});
}
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: CreateUserRequest) =>
apiClient.post<User>('/users', userData),
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// Component usage
function UserManagement() {
const { data: users, isLoading, error } = useUsers();
const createUser = useCreateUser();
const handleCreateUser = (userData: CreateUserRequest) => {
createUser.mutate(userData);
};
// Component renders...
}
This pattern shines when you need intelligent caching, optimistic updates, and automatic background synchronisation. It's particularly powerful for applications where data consistency and performance are critical.
Pattern 4: The Command Pattern for Complex Operations
For complex business operations that involve multiple API calls or complex error handling, the command pattern provides excellent organization and testability.
// commands/CreateProjectCommand.ts
interface CreateProjectCommand {
execute(data: CreateProjectData): Promise<Project>;
}
class CreateProjectCommandImpl implements CreateProjectCommand {
constructor(
private apiClient: ApiClient,
private analytics: AnalyticsService
) {}
async execute(data: CreateProjectData): Promise<Project> {
try {
// Step 1: Validate project name availability
const available = await this.apiClient.get<boolean>(
`/projects/check-availability?name=${data.name}`
);
if (!available) {
throw new Error('Project name is already taken');
}
// Step 2: Create the project
const project = await this.apiClient.post<Project>('/projects', data);
// Step 3: Initialize default settings
await this.apiClient.post(`/projects/${project.id}/settings`, {
theme: 'default',
notifications: true,
});
// Step 4: Track analytics
this.analytics.track('project_created', {
projectId: project.id,
projectType: data.type,
});
return project;
} catch (error) {
this.analytics.track('project_creation_failed', {
error: error.message,
});
throw error;
}
}
}
Commands excel at encapsulating business logic, making complex operations testable, and providing clear interfaces for multistep processes.
Pattern 5: The Event-Driven Pattern
For applications requiring real-time updates or loose coupling between components, an event-driven approach combined with WebSockets or Server-Sent Events creates responsive user experiences.
// services/RealtimeService.ts
class RealtimeService extends EventTarget {
private ws: WebSocket | null = null;
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.dispatchEvent(new CustomEvent(data.type, { detail: data.payload }));
};
}
subscribe<T>(eventType: string, handler: (data: T) => void) {
const listener = (event: CustomEvent) => handler(event.detail);
this.addEventListener(eventType, listener);
return () => this.removeEventListener(eventType, listener);
}
}
// Hook usage
function useRealtimeData(eventType: string) {
const [data, setData] = useState(null);
useEffect(() => {
const unsubscribe = realtimeService.subscribe(eventType, setData);
return unsubscribe;
}, [eventType]);
return data;
}
Choosing the Right Pattern
The key to successful API communication isn't using every pattern—it's choosing the right ones for your specific needs:
Start with the API Client pattern for any application. It's your foundation for consistent, maintainable API communication.
Add Custom Hooks (React) or equivalent composable (Vue) when you need component-level state management with good reusability.
Introduce Query Clients when caching, background updates, and data synchronization become important. Don't add this complexity until you need it.
Use Command Patterns for complex business operations that involve multiple steps, external services, or intricate error handling.
Implement Event-Driven patterns when you need real-time features or want to decouple components from direct API dependencies.
Summary
Effective API communication patterns transform your frontend from a collection of scattered API calls into a cohesive, maintainable system. The patterns we've explored provide different levels of abstraction and complexity, allowing you to scale your approach as your application grows.
Remember: patterns are tools, not rules. Start simple with a solid API client foundation, then add complexity only when your requirements demand it. Your future self (and your teammates) will thank you for the consistency and clarity these patterns bring to your codebase.
The best API pattern is the one your team can understand, maintain, and extend. Choose patterns that match your team's expertise and your application's complexity. Don't over-engineer early, but don't under-engineer when complexity is inevitable.
Additional References
- TanStack Query (React Query) Documentation: Comprehensive guide to data fetching, caching, and synchronisation
- SWR Documentation: Alternative data fetching library with similar capabilities
- Axios Documentation: Feature-rich HTTP client library
- TypeScript Handbook - Advanced Types: Advanced typing patterns for API responses
- React Error Boundaries: Handling API errors at the component level
- Testing Library - Async Utilities: Testing strategies for async API calls
- WebSocket API Documentation: Real-time communication patterns
- REST API Design Best Practices: Guidelines for designing consistent APIs
- GraphQL Documentation: Alternative API architecture and patterns
Top comments (5)
This is so bad to do and promote. Not surprising, since you reference the Axios documentation as a bibliographic reference.
The Ugly Truth: All Popular fetch() Wrappers Do It Wrong
José Pablo Ramírez Vargas ・ Mar 11
Thanks for the feedback! You raise a valid point about the complexity of error handling in production applications.
You're absolutely right that not all non-2xx responses should be treated as exceptions - 404s might be expected in some flows, and we definitely lose access to response body data when throwing immediately.
The example I provided is intentionally simplified to demonstrate the foundational pattern. In real-world applications, I'd typically recommend a more nuanced approach:
The pattern's strength is giving teams a consistent starting point that they can evolve based on their specific needs. Some teams prefer "throw early" for simplicity, others need the full response object for complex error handling.
Regarding Axios - it's included as one option among several HTTP client libraries, each with their own trade-offs. The goal was to provide resources for different architectural choices, rather than advocating for any particular library.
P.S. I'll also tweak my code snippet to accommodate your feedback and add a note pointing that it's the foundational pattern that developers can build upon.
Love how you broke this down into real, production-proven patterns. When did you feel it was the right time to move from a basic API client to bringing in React Query or SWR?
The switch usually makes sense when you hit these pain points:
My rule: Start simple with API client + hooks. Upgrade when data sync becomes harder than feature building.
Great thanks!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.