DEV Community

Cover image for Patterns for API Communication in Frontend Applications
Stanley J
Stanley J

Posted on

Patterns for API Communication in Frontend Applications

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

API Request Lifecycle


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');

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

Component Architecture with Custom Hooks


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...
}

Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

Comprehensive event-driven architecture diagram that illustrates the complete WebSocket communication flow. Here's what the diagram shows

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


Follow me on social

Top comments (5)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas
    if (!response.ok) {
      throw new ApiError(
        `HTTP ${response.status}: ${response.statusText}`,
        response.status
      );
    }
Enter fullscreen mode Exit fullscreen mode

This is so bad to do and promote. Not surprising, since you reference the Axios documentation as a bibliographic reference.

Collapse
 
istealersn_dev profile image
Stanley J

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:

// Option 1: Enhanced error with response data
if (!response.ok) {
  const errorData = await response.json().catch(() => ({}));
  throw new ApiError(message, status, errorData, response);
}

// Option 2: Let caller handle specific status codes
async get<T>(endpoint: string, options = { throwOn4xx: true }) {
  const response = await this.request(endpoint);
  if (!response.ok && this.shouldThrow(response.status, options)) {
    // throw logic
  }
  return { data: await response.json(), response };
}
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
dotallio profile image
Dotallio

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?

Collapse
 
istealersn_dev profile image
Stanley J

The switch usually makes sense when you hit these pain points:

  • Managing the same loading states across multiple components
  • Writing custom caching or dealing with stale data
  • Race conditions from duplicate requests
  • Need for background refetching

My rule: Start simple with API client + hooks. Upgrade when data sync becomes harder than feature building.

Collapse
 
casey_spaulding_ profile image
Casey Spaulding

Great thanks!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.