API Service Layers: Separating Business Logic from UI Components
Why direct fetch calls inside components are an anti-pattern — and how to build a proper service layer that makes your code testable, reusable, and maintainable.

One of the most pervasive anti-patterns in React codebases is the API call buried directly inside a component's useEffect. It works. It's fast to write. And it creates a maintenance nightmare. ## The Problem When you put a fetch call directly in a component, that component becomes tightly coupled to your API. You can't reuse it with different data. You can't test it without mocking the entire network. And when the API changes, you have to find every component that calls it. ```tsx // Anti-pattern useEffect(() => { fetch("/api/users") .then(res => res.json()) .then(data => setUsers(data)); }, []); ``` ## The Service Layer The solution is a dedicated service module for each resource. All API calls for users go through a user service: ```ts // features/user-management/services/user-service.ts import { apiClient } from "@/lib/api-client"; import type { User, CreateUserPayload } from "../types/user-type"; export async function getUsers(): Promise<User[]> { const response = await apiClient.get("/users"); return response.data; } export async function createUser(payload: CreateUserPayload): Promise<User> { const response = await apiClient.post("/users", payload); return response.data; } export async function deleteUser(id: string): Promise<void> { await apiClient.delete(`/users/${id}`); } ``` ## The Centralized API Client The service layer calls a single, centralized API client — never raw fetch: ```ts // lib/api-client.ts const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; export const apiClient = { async get<T>(url: string): Promise<{ data: T }> { const response = await fetch(`${BASE_URL}${url}`, { headers: { Authorization: `Bearer ${getToken()}` }, }); if (!response.ok) throw new Error("Request failed"); return response.json(); }, // post, put, delete... }; ``` This means: base URL in one place, authentication in one place, error handling in one place. When you change how auth tokens work, you change it once. ## Composing with Query Hooks The service layer composes cleanly with React Query or TanStack Query: ```ts // features/user-management/hooks/use-user-query.ts import { useQuery } from "@tanstack/react-query"; import { getUsers } from "../services/user-service"; export function useUserQuery() { return useQuery({ queryKey: ["users"], queryFn: getUsers, }); } ``` Your component then becomes clean, readable, and free of any data-fetching concerns: ```tsx export function UserList() { const { data: users, isLoading, isError } = useUserQuery(); if (isLoading) return <UserTableSkeleton />; if (isError) return <ErrorState message="Failed to load users" />; if (!users?.length) return <EmptyState message="No users found" />; return <UserTable users={users} />; } ``` This is clean code. Each layer has one job. The component renders. The hook manages state. The service fetches. The client handles HTTP.

