API & Data Fetching

One Axios instance. One place that knows how to authenticate. One place that knows how to retry. Every component just calls a service function and gets data — they shouldn’t know about headers, tokens, or refresh logic.


One Axios instance, configured once

I never import axios from "axios" inside a component. I import a pre-configured instance from global/api/:

// global/api/axios-instance.ts
import axios from "axios";

export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10_000,
  headers: { "Content-Type": "application/json" },
});

This makes it trivial to add cross-cutting concerns: auth, logging, request IDs, retries — all in one place.


Services own the URL, components own the UI

Components don’t know endpoint paths. They call a service function that returns typed data:

// features/orders/services/orders-service.ts
import { apiClient } from "@/global/api";
import type { Order, OrderFilters } from "../@types";

export const ordersService = {
  list: (filters: OrderFilters) =>
    apiClient.get<Order[]>("/orders", { params: filters }).then((r) => r.data),

  get: (id: string) =>
    apiClient.get<Order>(`/orders/${id}`).then((r) => r.data),

  create: (payload: Omit<Order, "id">) =>
    apiClient.post<Order>("/orders", payload).then((r) => r.data),
};

The service is the only file that knows the endpoint shape. If the API changes, I update one place.


React Query wraps the service

// features/orders/hooks/use-orders.ts
import { useQuery } from "@tanstack/react-query";
import { ordersService } from "../services/orders-service";

export const useOrders = (filters: OrderFilters) =>
  useQuery({
    queryKey: ["orders", filters],
    queryFn: () => ordersService.list(filters),
  });

The hook is what components use. The service is pure data access. The two-layer split is worth the extra file — it lets me test services without React and reuse them from background jobs, server actions, or scripts.


Auth lives in interceptors, not in every call

A request interceptor attaches the access token; a response interceptor handles 401s by refreshing and retrying. I write this once per project and never touch it from a component.

The full pattern — including the queue that prevents N parallel refresh calls — is in the JWT post: Auto-refreshing JWTs with Axios →


Errors get typed and thrown

Axios errors are not friendly. I wrap them into a project-level error type at the response interceptor so the rest of the app deals with one shape:

export class ApiError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
    public details?: unknown,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

apiClient.interceptors.response.use(
  (r) => r,
  (error) => {
    if (error.response) {
      const { status, data } = error.response;
      throw new ApiError(
        status,
        data?.code ?? "UNKNOWN",
        data?.message ?? "Request failed",
        data,
      );
    }
    throw new ApiError(0, "NETWORK", "Network error");
  },
);

Now every catch block can rely on instanceof ApiError and discriminated error.codes instead of digging through error.response.data.message.


Mutations: optimistic when it matters, pessimistic otherwise

For high-frequency UI (likes, toggles, drag-and-drop reorders), I use optimistic updates so the UI responds instantly:

const { mutate } = useMutation({
  mutationFn: ordersService.update,
  onMutate: async (next) => {
    await queryClient.cancelQueries({ queryKey: ["orders"] });
    const prev = queryClient.getQueryData<Order[]>(["orders"]);
    queryClient.setQueryData<Order[]>(["orders"], (old) =>
      old?.map((o) => (o.id === next.id ? next : o)),
    );
    return { prev };
  },
  onError: (_e, _v, ctx) => {
    if (ctx?.prev) queryClient.setQueryData(["orders"], ctx.prev);
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
});

For destructive or financial actions (delete account, submit payment), I stay pessimistic — wait for the server, then update. The UX cost of a 200ms spinner is much smaller than the cost of “your payment looked successful but actually wasn’t.”


The end-to-end shape

features/orders/
├── services/orders-service.ts   # axios calls, returns typed data
├── hooks/use-orders.ts          # React Query wrappers
├── hooks/use-create-order.ts    # mutations
├── components/OrdersList.tsx    # consumes the hook
└── @types/order.ts              # the Order type

Each layer has one job. When something breaks, I know exactly which file to open.