Overview
Centralized API clients are shared modules that define how a React application talks to backend services. Instead of scattering raw fetch or Axios calls across components, the app uses a small set of API utilities, service functions, generated hooks, or query definitions.
This matters because production React apps need consistent behavior for:
- Base URLs.
- Request headers.
- Authentication.
- JSON parsing.
- Error normalization.
- Timeouts and cancellation.
- Retries.
- Cache invalidation.
- Loading and mutation states.
- Logging and observability.
- Type-safe request and response shapes.
Centralization does not mean every endpoint must live in one giant file. It means the app has a clear boundary for server communication. Components should usually describe what data they need or what mutation they want to perform, not duplicate low-level HTTP details.
For interviews, this topic is important because it tests whether a candidate understands the difference between UI state and server state, when a lightweight wrapper is enough, when a full data-fetching library is useful, and how to avoid auth, caching, and error handling bugs as an application grows.
Core Concepts
What a Centralized API Client Is
A centralized API client is a shared layer that hides repetitive transport details behind a predictable interface.
Common responsibilities:
- Build URLs from a base API path.
- Serialize request bodies.
- Parse JSON responses.
- Check HTTP status codes.
- Attach authentication headers when appropriate.
- Convert network and HTTP failures into application-level errors.
- Support aborting requests.
- Apply common timeout or retry behavior.
- Expose typed service functions or hooks to the UI.
Example:
type ApiError = {
status: number;
message: string;
details?: unknown;
};
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const response = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
...options,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const body = await response.json().catch(() => undefined);
throw {
status: response.status,
message: body?.message ?? "Request failed",
details: body,
} satisfies ApiError;
}
return response.json() as Promise<T>;
}
This wrapper gives components one behavior for error handling and response parsing instead of making every component remember to check response.ok.
Why Raw HTTP Calls in Components Become a Problem
Calling APIs directly inside components can be acceptable for prototypes or tiny screens, but it scales poorly.
Common issues:
- Every component reimplements loading, error, and success states.
- Some calls check
response.ok, while others forget. - Authentication headers are attached inconsistently.
- Error shapes vary from screen to screen.
- Cancellation is forgotten when components unmount or inputs change.
- Duplicate requests are made for the same data.
- Cache invalidation becomes guesswork.
- Tests must mock many unrelated HTTP details.
The goal of an API client is not to hide HTTP completely. The goal is to make common behavior boring and consistent.
Centralization vs Over-Centralization
Good centralization removes repetition without creating a giant, fragile abstraction.
Good centralization:
- Keeps transport details in one place.
- Keeps endpoint-specific logic near the feature that owns it.
- Uses typed request and response contracts.
- Lets callers pass needed options such as
signal. - Makes errors predictable.
Over-centralization:
- Puts every endpoint in one enormous
api.ts. - Hides important behavior behind magic flags.
- Builds a custom caching framework when a proven library would be clearer.
- Forces unrelated APIs into one interface.
- Makes feature teams edit the same file for every endpoint.
A practical structure is often:
src/
api/
httpClient.ts
apiError.ts
features/
users/
usersApi.ts
usersQueries.ts
orders/
ordersApi.ts
ordersQueries.ts
The shared httpClient.ts handles transport. Feature API files define domain-specific operations.
fetch as a Lightweight API Client
fetch is built into modern browsers and returns a promise that resolves to a Response. It is a good choice when an app wants minimal dependencies and has simple HTTP needs.
Important fetch details for interviews:
fetchrejects for network-level failures, not for HTTP error statuses such as400or500.- Callers must check
response.okor inspectresponse.status. - Response body parsing is explicit, such as
response.json()orresponse.text(). - Cancellation uses
AbortControllerandAbortSignal. - Cookies and credentials are controlled with the
credentialsoption. - Headers and request bodies must be built explicitly.
Example with cancellation:
export async function getUser(userId: string, signal?: AbortSignal) {
return apiFetch<User>(`/users/${userId}`, { signal });
}
Used in a component:
useEffect(() => {
const controller = new AbortController();
getUser(userId, controller.signal)
.then(setUser)
.catch((error) => {
if (error.name !== "AbortError") {
setError(error);
}
});
return () => controller.abort();
}, [userId]);
This is useful when the app does not need a full server-state cache, but it does put more responsibility on the team to handle request deduplication, caching, and retries.
Axios as a Centralized HTTP Client
Axios is a promise-based HTTP client that adds convenience around request configuration, JSON handling, timeouts, interceptors, cancellation, and error objects.
Typical Axios client:
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10_000,
headers: {
Accept: "application/json",
},
});
Feature service:
export async function getProfile() {
const response = await api.get<UserProfile>("/me");
return response.data;
}
Axios can be a good choice when the team wants:
- Request and response interceptors.
- Built-in timeout configuration.
- Consistent error objects.
- Per-service instances.
- A mature client usable in browser and Node-based tooling.
The main mistake is putting global defaults on the shared Axios object when the app talks to multiple domains. Credentials and auth headers should usually be scoped to an instance with a known baseURL.
RTK Query as an API Client and Cache Layer
RTK Query is a data fetching and caching tool included with Redux Toolkit. It is useful when the app already uses Redux Toolkit or wants API definitions integrated with Redux state and generated React hooks.
Example API slice:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
type User = {
id: string;
name: string;
};
export const usersApi = createApi({
reducerPath: "usersApi",
baseQuery: fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = selectAccessToken(getState());
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
}),
tagTypes: ["User"],
endpoints: (build) => ({
getUser: build.query<User, string>({
query: (id) => `/users/${id}`,
providesTags: (_result, _error, id) => [{ type: "User", id }],
}),
updateUser: build.mutation<User, Partial<User> & Pick<User, "id">>({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: "PATCH",
body: patch,
}),
invalidatesTags: (_result, _error, { id }) => [{ type: "User", id }],
}),
}),
});
export const { useGetUserQuery, useUpdateUserMutation } = usersApi;
RTK Query centralizes:
- Endpoint definitions.
- Generated hooks.
- Loading and error state.
- Cache lifetimes.
- Cache invalidation with tags.
- Request header preparation.
- Response and error transformations.
The trade-off is that RTK Query is tied to Redux Toolkit infrastructure. If the app does not otherwise use Redux, TanStack Query or a smaller wrapper may be simpler.
TanStack Query as a Server-State Client
TanStack Query is a server-state library. It does not require Redux and works with any promise-returning function. The API client still performs HTTP transport, while TanStack Query manages query keys, caching, request deduplication, retries, refetching, stale state, and mutation coordination.
Example:
export function getUser(userId: string) {
return apiFetch<User>(`/users/${userId}`);
}
export function useUser(userId: string) {
return useQuery({
queryKey: ["user", userId],
queryFn: () => getUser(userId),
staleTime: 60_000,
});
}
Mutation with invalidation:
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ["user", updated.id] });
},
});
}
TanStack Query is often the right abstraction when:
- The app has repeated reads of server data.
- Multiple components need the same data.
- Background refetching is useful.
- Loading, error, stale, and fetching states need to be consistent.
- Optimistic updates or mutation invalidation matter.
- You do not want to store remote data manually in React state.
It is not a replacement for the HTTP client. The query function still needs a reliable fetch or Axios wrapper.
Choosing Between fetch, Axios, RTK Query, and TanStack Query
The choice depends on the problem.
Use a fetch wrapper when:
- The app is small or has simple server communication.
- You want no extra dependency.
- You can manage loading, retries, and caching manually or through route loaders.
- You only need a consistent transport layer.
Use Axios when:
- You want configured instances, interceptors, timeouts, and consistent error objects.
- Your app has multiple backends or needs per-service clients.
- You need to integrate auth headers, logging, retry, or response normalization at the HTTP layer.
Use RTK Query when:
- The app already uses Redux Toolkit.
- You want endpoint definitions, generated hooks, and cache invalidation tied to Redux.
- You prefer tags and API slices as the data access model.
Use TanStack Query when:
- You want server-state caching without Redux.
- Query keys, stale data, background refetching, retries, and mutations are central to the app.
- You want to keep transport code separate from server-state behavior.
The best answer in an interview is rarely "always use X." A better answer explains the app size, team conventions, cache complexity, authentication model, and operational needs.
Error Normalization
A strong API client returns or throws predictable errors. Components should not need to know every backend error shape.
Example:
export class ApiClientError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly details?: unknown,
) {
super(message);
}
}
async function parseError(response: Response) {
const body = await response.json().catch(() => undefined);
return new ApiClientError(
body?.message ?? `Request failed with status ${response.status}`,
response.status,
body,
);
}
This makes UI code simpler:
if (error instanceof ApiClientError && error.status === 401) {
return <SignInExpiredMessage />;
}
Error normalization is especially important when an app mixes REST endpoints, generated clients, and third-party APIs.
Authentication Headers and Credentials
Centralized clients commonly attach auth data, but this must be done carefully.
For bearer tokens:
- Attach the
Authorizationheader only to trusted API origins. - Avoid global defaults that send tokens to third-party domains.
- Keep token lookup close to request time so refreshed tokens are used.
- Avoid logging headers or serialized configs that contain secrets.
For cookie-based auth:
- Configure
credentialsforfetchonly when needed. - Configure Axios
withCredentialsonly for trusted cross-origin APIs. - Account for
SameSite, CORS, CSRF, and secure cookie settings.
Example:
const api = axios.create({
baseURL: "/api",
withCredentials: true,
});
The key interview point: auth belongs in the client boundary, but security decisions belong in the full request model, including browser storage, cookies, CORS, CSRF, token refresh, and logging.
Cancellation and Race Conditions
Cancellation prevents stale or irrelevant requests from updating the UI.
Common cases:
- Search input changes while an old request is still in flight.
- A component unmounts before the request completes.
- A route changes while a loader or query is still running.
- A user clicks a different entity before the previous details request returns.
With fetch, use AbortController. With Axios, use the signal option. With TanStack Query and RTK Query, understand how the library cancels or ignores stale requests and how query keys affect request identity.
Example:
export async function searchUsers(term: string, signal?: AbortSignal) {
return apiFetch<User[]>(`/users?search=${encodeURIComponent(term)}`, {
signal,
});
}
Cancellation is not only a performance feature. It prevents incorrect UI state.
Type Safety and Runtime Validation
TypeScript types help callers understand expected request and response shapes, but TypeScript does not validate runtime JSON from a server.
Options:
- Use TypeScript interfaces for internal contracts where the API is trusted.
- Use schema validation for high-risk boundaries.
- Generate API clients from OpenAPI when contracts are stable.
- Normalize response DTOs before the UI consumes them.
Example:
type UserDto = {
id: string;
displayName: string;
};
type UserViewModel = {
id: string;
name: string;
};
function mapUser(dto: UserDto): UserViewModel {
return {
id: dto.id,
name: dto.displayName,
};
}
Do not let backend DTO quirks leak into every component. Map at the boundary when the UI model differs from the API model.
Testing Centralized API Clients
Centralization improves testability.
Useful tests:
- Unit tests for URL construction and error normalization.
- Integration tests with mocked network responses.
- Tests that verify auth headers are scoped correctly.
- Tests for retries and timeout behavior.
- Tests for cache invalidation after mutations.
Example test target:
await expect(apiFetch("/missing")).rejects.toMatchObject({
status: 404,
message: "Not found",
});
Avoid tests that mock the entire data-fetching library for every component. Prefer testing feature behavior and using network-level mocks when possible.
Common Mistakes
Common mistakes include:
- Calling
fetchand forgetting that HTTP500does not reject automatically. - Putting every endpoint in one massive client file.
- Sending auth headers through global Axios defaults to multiple domains.
- Duplicating server data in local React state after using TanStack Query or RTK Query.
- Using both RTK Query and TanStack Query for the same data without a clear reason.
- Retrying non-idempotent mutations blindly.
- Swallowing errors in the client and leaving UI stuck in loading state.
- Creating new query keys or client instances on every render.
- Hiding request cancellation so callers cannot pass an
AbortSignal.
Best Practices
Best practices include:
- Create a small shared transport client.
- Keep endpoint-specific functions close to their feature.
- Normalize errors once.
- Scope credentials and headers to trusted API origins.
- Use stable query keys.
- Use server-state libraries for cache-heavy UI instead of hand-rolled state.
- Keep mutation invalidation explicit.
- Pass cancellation signals through the stack.
- Avoid leaking backend DTO shapes into every component.
- Log enough context to debug failures without logging secrets.
- Prefer boring, predictable behavior over clever abstractions.