WebMriya
Back to articles

Keep API Clients Out of React Components

A practical React architecture for separating API clients, server state, and UI behavior before async code turns components into untestable modules.

React
TypeScript
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
October 11, 2026 · 5 min read

A familiar problem appears when a React product grows: a component that started as a simple screen now imports an API client, builds request parameters, manages loading and error flags, transforms the response, triggers notifications, and decides what to render. It still works, but every new requirement makes the component harder to test and easier to break.

The issue is not that the component contains asynchronous code. The issue is that several different responsibilities have been mixed together: talking to the backend, representing server state, applying product rules, and rendering an interaction. Separating those responsibilities gives each part a smaller contract and makes changes safer.

What belongs where

A useful boundary is to treat the API client as an infrastructure detail, the query or mutation hook as the server-state adapter, and the component as the place where UI decisions are made. The component should know what data it needs and what action the user can take. It should not need to know whether the data came from fetch, Axios, GraphQL, or a local development stub.

ResponsibilityGood location
HTTP method, URL, headers, response parsingAPI client
Caching, retries, refetching, request statusQuery or mutation hook
Business-specific mapping and validationDomain adapter
Visibility, disabled states, empty statesReact component
Notifications and navigation after an actionFeature-level hook or component

This does not mean creating five files for every endpoint. The boundary is valuable when it reduces knowledge that would otherwise be duplicated across screens. A small feature may use one client function and one hook. A larger feature may add a domain adapter when backend data does not match the shape the UI should consume.

Start with a narrow API contract

The first improvement is usually removing raw request details from JSX-oriented code. Give the feature a typed function with a name that describes the operation, not the transport mechanism. The function should return the data the feature actually needs and should normalize backend-specific details at the boundary.

ts
export type Project = {
  id: string;
  name: string;
  status: "active" | "archived";
};

export async function getProject(projectId: string): Promise<Project> {
  const response = await fetch(`/api/projects/${projectId}`);

  if (!response.ok) {
    throw new Error("Unable to load project");
  }

  return response.json() as Promise<Project>;
}

This example is intentionally small, but it establishes an important contract: callers do not construct URLs or inspect HTTP status codes. In production, runtime validation should usually be added at this boundary because TypeScript types disappear when the request reaches the server. The article on runtime validation at API boundaries covers that gap in more detail.

Use hooks as adapters, not as second components

A feature hook should expose server state in a form the screen can consume. It can choose a query key, configure stale time, map errors, and provide mutation functions. It should avoid accumulating unrelated presentation decisions such as opening a modal or changing five local flags.

ts
import { useQuery } from "@tanstack/react-query";
import { getProject } from "./project-api";

export function useProject(projectId: string) {
  return useQuery({
    queryKey: ["project", projectId],
    queryFn: () => getProject(projectId),
    enabled: projectId.length > 0,
  });
}

The component can now focus on states that users recognize: loading, unavailable, empty, ready, and retrying. It does not need separate effects to synchronize a response into local state, because the query already owns the lifecycle of server data. For form-heavy flows, the same separation helps keep field state and server errors distinct; compare it with the approach described in React Forms Without State Sprawl.

Do not copy server state into local state by default

A common refactor introduces a query and then immediately copies its result into useState. That creates two sources of truth. When a background refetch returns newer data, the local copy may remain stale. When a mutation succeeds, the component may update one copy while the cache still contains another.

  • Keep fetched data in the query cache unless the user is editing a temporary draft.
  • Use local state for interaction state: an open panel, a selected row, or an unsaved input.
  • When a draft is submitted, invalidate or update the relevant server-state entry rather than manually synchronizing every screen.
  • If a response must be transformed, make the transformation explicit and test it independently.

Keep error handling meaningful

An API client should throw an error that preserves enough context for the next layer to make a decision. A component should not need to distinguish a 401, a validation failure, a timeout, and a server outage by parsing a string such as "Request failed." At the same time, low-level transport details should not leak into every button.

A practical error shape can include a stable code, an HTTP status, and optional field errors. The feature hook can map those values into a small set of UI outcomes: redirect to sign-in, show inline validation, offer retry, or display a generic failure message. This keeps recovery behavior deliberate instead of scattering status checks through render logic.

Test the boundary you actually own

When components call a concrete API client directly, tests often need to mock fetch, timers, notifications, navigation, and several implementation details at once. A narrower hook contract lets component tests provide controlled data and focus on rendering and interaction. API-client tests can separately verify URL construction, parsing, and error conversion.

  1. Test API functions against representative success and failure responses.
  2. Test domain adapters with malformed or incomplete payloads.
  3. Test hooks for query keys, enabled conditions, and mutation side effects.
  4. Test components for user-visible states and actions, not for internal request mechanics.

When this separation is worth the effort

Do not turn a small page into an architecture exercise. Direct calls may be acceptable for a short-lived internal screen with one request and no reuse. The separation becomes valuable when several components use the same data, caching matters, the backend contract is unstable, the feature has optimistic updates, or the team needs to change the transport layer without rewriting the UI.

A good rule is to move code across the boundary when the same knowledge appears in more than one place. If three components know the endpoint shape, centralize it. If two screens implement the same retry behavior, extract the adapter. If a hook starts deciding layout or modal behavior, push that decision back toward the feature UI.

The result is less fragile React

Keeping API clients out of React components is not about following a fashionable folder structure. It is about controlling change. Transport details can evolve without rewriting screens, server-state behavior can be tested without rendering a page, and components can represent the states users actually experience.

This boundary is especially useful in products with several interactive workflows and shared data. If your application has accumulated tightly coupled async logic, a dedicated product UI team can help establish these boundaries while continuing to ship features, rather than treating architecture as a separate rewrite.

Frequently asked questions

Should every API call have its own hook?

No. Create a hook when the operation has reusable server-state behavior, caching, lifecycle rules, or feature-specific mapping. A one-off request in a small screen may not need another abstraction.

Can a React component call fetch directly?

It can, but direct calls become expensive when request logic is reused or the screen needs caching, retries, and consistent error handling. A typed API function keeps those details out of rendering code.

Where should response validation happen?

Validate external data at the API boundary, before it spreads through the application. The rest of the feature can then work with a trusted domain shape.