Structuring an API Layer: From Component to Backend

Learn to separate a React app's networking concerns into distinct layers — component, custom hook, API/service layer, HTTP client, backend — and understand DTOs, domain models, request/response models, centralized error handling, authentication, and runtime validation within that layered structure.

A component that calls fetch() directly inside a useEffect works fine for a single prototype screen, but as an app grows, scattering raw fetch calls, URL strings, and error-handling logic across dozens of components becomes unmaintainable — the same endpoint gets called slightly differently in five places, auth headers are duplicated or forgotten, and a backend field rename breaks components that have no business knowing the backend's exact shape. A layered API architecture exists to fix this by giving each concern exactly one place to live.

The five-layer flow

A well-structured React app's data flow looks like this: Component ↓ Custom Hook ↓ API / Service Layer ↓ HTTP Client ↓ Backend Each layer only talks to the one directly below it, and each has exactly one job. The Component renders UI and reacts to state; it should not know what URL, HTTP method, or headers are involved in getting its data. The Custom Hook (useProducts, useCreateOrder) is the bridge — it owns loading/error/data state and calls into the service layer, exposing a clean, React-friendly interface (data, isLoading, error, refetch) that components consume.

The API / Service Layer (often organized as one module per resource — productsApi.ts, ordersApi.ts) contains the actual functions that know which endpoint to call, what request shape the backend expects, and how to convert the raw response into something the rest of the app can use. Below that, the HTTP Client is a thin, shared wrapper around fetch (or a library like axios) that centralizes cross-cutting concerns — base URL, default headers, auth token attachment, timeout, and error normalization — so every service-layer function benefits from that behavior without repeating it. The Backend is, of course, outside the frontend entirely, but the layers above exist specifically to insulate the rest of the app from its exact shape and quirks.

The payoff of this separation is that a change at any one layer stays contained: swapping fetch for axios only touches the HTTP client; a backend field rename only touches the mapping function in the service layer; adding a retry policy only touches the HTTP client or service layer, not every component that happens to fetch something.

API client, DTOs, and domain models

An API client is the concrete object or module that implements the HTTP Client layer — commonly a small class or factory function configured once (base URL, default headers) and exported as a singleton, so every service-layer function shares the same configuration and auth handling instead of each one reinventing it.

A DTO (Data Transfer Object) is the shape of data exactly as the backend sends or expects it over the wire — field names, casing, and nesting are whatever the API contract says, even if that's inconsistent with how the frontend would ideally model the same concept (snake_case IDs, a status field that's really a numeric enum, a date as an ISO string). A domain model is the shape the rest of the frontend actually works with — camelCase, proper Date objects, a friendly string union instead of a numeric enum — designed for what the app needs, not for what the backend happens to send.

The service layer's job includes mapping between these two: a function like function toDomainProduct(dto: ProductDto): Product converts the wire shape into the app's shape immediately after a response arrives, so no component ever sees a raw DTO. This mapping step is what lets the backend's contract change (a renamed field, a restructured nested object) without a ripple effect through every component that displays a product — only the one mapping function needs to update.

Request models, response models, and error models

A request model is the shape of data the frontend sends when making a write (a CreateProductRequest with just the fields the create endpoint needs), distinct from the full domain model — a request to create a product doesn't include an id or createdAt, since the backend assigns those; conflating 'the shape I send' with 'the shape I display' invites bugs like accidentally sending a stale id on a create call.

A response model is the DTO shape the backend actually returns for a given call, which is often not identical to the domain model either — a list endpoint might return { items: ProductDto[]; total: number; page: number } rather than a bare array, and defining that shape explicitly (rather than typing the response as any or as ProductDto[]) keeps the service layer's mapping logic honest about what it's actually receiving.

An error model standardizes what a failure looks like once it's traveled up out of the HTTP client, regardless of whether it originated from a network failure, a non-2xx response, or a response body that failed validation — typically a class like ApiError { status: number; code: string; message: string } that every service-layer function throws instead of letting raw fetch rejections or inconsistent backend error bodies leak upward. Centralizing API errors this way means every calling hook and component can catch exactly one error type and rely on it having a predictable shape, instead of having to guard against a fetch TypeError, a parsed-JSON error object, and a plain string message all being possible depending on what failed.

Authentication handling and runtime validation

Authentication handling belongs in the HTTP client layer precisely because it's cross-cutting: every authenticated request needs an Authorization header attached, and many APIs need an expired token refreshed transparently and the original request retried once. Implementing this once, in the client's request pipeline (an interceptor-style hook that runs before every request and on every 401 response), means service-layer functions never manually attach a token or handle a 401 themselves — they just call the client, and auth is handled underneath them.

Runtime validation addresses a gap TypeScript's own type system cannot close: TypeScript types (like ProductDto) exist only at compile time and are erased entirely by the time the app runs, so const data = await res.json() as ProductDto is a claim, not a guarantee — if the backend actually returns something with a missing or differently-typed field, TypeScript will not catch it, and the bug surfaces later as a confusing runtime crash somewhere the mismatched value gets used.

The fix is validating the response shape at runtime, typically at the service-layer boundary, using a schema library (Zod is the most common choice in the React ecosystem) that both defines the expected shape and checks a value against it at runtime, throwing (or returning a typed error) if the actual response doesn't match — turning a silent, delayed type mismatch into an immediate, loud, and precisely located failure exactly where the untrusted data enters the app.

HTTP client, service layer function, and DTO-to-domain mapping

httpClient knows nothing about products — only about base URL, headers, auth, and normalizing errors into ApiError. productsApi knows the endpoint path and the DTO-to-domain mapping, but nothing about fetch, headers, or tokens.

// httpClient.ts — the HTTP Client layer
class ApiError extends Error {
  constructor(public status: number, public code: string, message: string) {
    super(message);
  }
}

async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = getAuthToken();
  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new ApiError(res.status, body?.code ?? "UNKNOWN", body?.message ?? res.statusText);
  }
  return res.json() as Promise<T>;
}

export const httpClient = {
  get: <T>(path: string) => request<T>(path),
  post: <T>(path: string, body: unknown) =>
    request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

// productsApi.ts — the API / Service Layer
interface ProductDto { id: string; name: string; price_cents: number }
interface Product { id: string; name: string; priceCents: number }

function toDomainProduct(dto: ProductDto): Product {
  return { id: dto.id, name: dto.name, priceCents: d
…

Code Examples

The component only ever sees a Product domain model or an ApiError — it has no idea the backend actually calls it price_cents, and no idea what URL or headers were involved. The custom hook is the only thing that talks to the service layer.

fetchProduct(id).then(...).catch((error: ApiError) =>...) httpClient.get is typed as returning unknown rather than trusting an as ProductDto cast; ProductDtoSchema.parse actually checks the response at runtime and throws immediately if a field is missing or the wrong type, instead of letting bad data silently flow into the rest of the app.

Common mistake: Trusting await res.json() as ProductDto as if the as cast were a real guarantee — TypeScript erases at compile time and performs no runtime check, so a backend response that doesn't actually match the type will pass silently until something downstream crashes.

function useProduct(id: string) {
  const [state, setState] = useState<
    { status: "loading" } | { status: "error"; error: ApiError } | { status: "success"; data: Product }
  >({ status: "loading" });

  useEffect(() => {
    setState({ status: "loading" });
    fetchProduct(id)
      .then((data) => setState({ status: "success", data }))
      .catch((error: ApiError) => setState({ status: "error", error }));
  }, [id]);

  return state;
}

function ProductPage({ id }: { id: string }) {
  const state = useProduct(id);
  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <p>Error {state.error.status}: {state.error.message}</p>;
  return <h1>{state.data.name} — ${(state.data.priceCents / 100).toFixed(2)}</h1>;
}
import { z } from "zod";

const ProductDtoSchema = z.object({
  id: z.string(),
  name: z.string(),
  price_cents: z.number().int().nonnegative(),
});

export async function fetchProduct(id: string): Promise<Product> {
  const raw = await httpClient.get<unknown>(`/products/${id}`);
  const dto = ProductDtoSchema.parse(raw); // throws ZodError if the shape doesn't match
  return toDomainProduct(dto);
}

The five-layer flow

Each arrow is a one-way dependency: a layer only calls the one below it, and only that layer's own concern lives there.

Component
   |  (renders UI, holds no fetch/URL knowledge)
   v
Custom Hook          <- owns loading/error/data state
   v
API / Service Layer  <- knows endpoints, DTO<->domain mapping
   v
HTTP Client          <- base URL, headers, auth, ApiError normalization
   v
Backend

Important Rules

Never call fetch directly from a component — route every request through the API/service layer via a custom hook.

Keep DTOs (wire shape) and domain models (app shape) as distinct types, and map between them in exactly one place, right where the response arrives.

Normalize every failure — network error, non-2xx response, validation failure — into one consistent error model so calling code only ever catches one type.

Attach authentication headers and handle token refresh in the HTTP client layer, not scattered across individual service-layer calls.

Validate untrusted response data at runtime at the service-layer boundary — a TypeScript type alone guarantees nothing once the app is actually running.

Where this shows up

When a backend team renames price_cents to priceCents (or restructures a nested object) in a v2 API, a well-layered frontend updates exactly one mapping function in the service layer — every component and hook that consumes Product keeps working unchanged, because they were never coupled to the DTO's exact shape in the first place. Teams that skipped the layering discover the rename by chasing down every component that happened to read response.price_cents directly.

function ProductPage({ id }: { id: string }) { const [product, setProduct] = useState(null); useEffect(() => { fetch(https://api.example.com/v1/products/${id}, { headers: { Authorization: Bearer ${localStorage.getItem("token")} }, }).then((r) => r.json()).then(setProduct); }, [id]); return <h1>{product?.name}</h1>; } function ProductPage({ id }: { id: string }) { const state = useProduct(id); // hook -> service layer -> http client if (state.status !== "success") return null; return <h1>{state.data.name}</h1>; }

The left version hardcodes the base URL, the auth mechanism, and the raw response shape directly in a component — if the auth scheme, base URL, or backend field names change, every component that fetches data has to be found and updated individually instead of one shared layer.

[multiple-choice] In the layered flow Component -> Custom Hook -> API/Service Layer -> HTTP Client -> Backend, which layer owns loading/error/data state? A. The Component B. The Custom Hook C. The HTTP Client D. The Backend

B. The Custom Hook — The custom hook is the bridge between React's rendering world and the plain-function service layer — it's the layer responsible for tracking loading, error, and data state and exposing it to components.

[multiple-choice] What is the key difference between a DTO and a domain model? A. There is no difference; they're two names for the same thing B. A DTO is the backend's wire shape; a domain model is the shape the frontend is designed to work with C. A DTO is only used for POST requests, a domain model only for GET requests D. A domain model is always a subset of a DTO's fields

B. A DTO is the backend's wire shape; a domain model is the shape the frontend is designed to work with — A DTO reflects exactly what the backend sends over the wire (field names, casing, nesting as the API contract defines them), while a domain model is the shape the rest of the app is designed around — the service layer maps between the two.

[true-false] Casting a fetch response with as ProductDto guarantees at runtime that the response actually has that shape. A. True B. False

B. False — TypeScript types are erased at compile time and enforce nothing at runtime — an as cast is just telling the compiler to trust you, which is why runtime validation (e.g. with Zod) is needed at the boundary where untrusted data enters the app.

Add a createProduct function to productsApi.ts that takes a CreateProductRequest ({ name: string; priceCents: number }), posts it to /products (converting priceCents back to price_cents for the wire), validates the response with a Zod schema, and returns the mapped domain Product.

interface CreateProductRequest { name: string; priceCents: number } export async function createProduct(req: CreateProductRequest): Promise<Product> { const raw = await httpClient.post<unknown>("/products", { name: req.name, price_cents: req.priceCents, }); const dto = ProductDtoSchema.parse(raw); return toDomainProduct(dto); }

Mirror fetchProduct's structure: build the request DTO shape for the POST body, call httpClient.post, run the parsed response through ProductDtoSchema.parse, then toDomainProduct.