04 — The data layer: one client, seventy wrappers
Read this first: this chapter opens frontend/src/api/ and discovers that every HTTP request in
the app — all sixty-plus entity modules of it — flows through one 100-line axios instance, and that
the interesting engineering lives in that one file. You will read the token refresh mechanism that
keeps a page with six in-flight requests from logging you out, and come away able to add an endpoint
exactly the way the other seventy were added.
Time: about 30 minutes. Assumes lesson 03.
Three layers, and what each one is forbidden to know
The data layer is a strict stack. Read the imports at the top of each file and you can see the rules being enforced:
hooks/api/useEmployees.ts React Query: caching, invalidation, loading state
│
api/employees.ts typed axios wrappers: one function per endpoint, zero React
│
api/client.ts the axios instance: auth header, 401 refresh, param encoding
│
api/types.ts every DTO interface, one shared file
The rule: api/*.ts never imports React, hooks, or components. A wrapper is callable from a
React Query queryFn, from AG Grid's fetchPage callback, from a test — anywhere. The moment a
wrapper knows about caching or component state, it can only be called from one place, and the grid
layer (lesson 08) needs to call the same functions the hooks do.
One types file, on purpose
api/types.ts is one 2,700-line file holding every DTO interface in the frontend. That looks like a
file that forgot to be split. It is the opposite: some 270 files across the frontend import from it,
and DTOs refuse to respect module boundaries — payroll screens render EmployeeDto, reports return
employee rows, the timesheet drawer shows position data. Per-module type files would either
duplicate those shapes or import each other in circles. One file mirrors the backend, where the same
DTOs live in one package, and gives every shape exactly one definition to drift from.
The shared shapes at the top are the ones you will use most:
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
currentPage: number;
pageSize: number;
}
export interface ApiErrorResponse {
message: string;
status: number;
time: string;
/**
* Which request field the caller should fix, when the server knows. Absent on most responses —
* a form-level message is the right answer when no single input is at fault. Signup's 409 uses
* it so "that address is taken" lands under the address box instead of in a banner.
*/
field?: string;
}
Every paginated endpoint returns PageResponse<T>; every error returns ApiErrorResponse. The
field? doc comment is the pattern this course keeps returning to: the decision, and the
incident-shaped reason for it, written where you will trip over it.
A wrapper is a typed sentence
api/employees.ts is the worked example the reference doc points at, and it earns it. The read
side:
export const listEmployees = async (params: ListEmployeesParams): Promise<PageResponse<EmployeeDto>> => {
const { data } = await apiClient.get<PageResponse<EmployeeDto>>('/api/employees', { params });
return data;
};
export const getEmployee = async (id: number): Promise<EmployeeDto> => {
const { data } = await apiClient.get<EmployeeDto>(`/api/employees/${id}`);
return data;
};
And the write side, one function per operation, named after the operation:
export const createEmployee = async (payload: EmployeeRequest): Promise<EmployeeDto> => {
const { data } = await apiClient.post<EmployeeDto>('/api/employees', payload);
return data;
};
// … updateEmployeeStatus, archiveEmployee, restoreEmployee, getMyProfile, updateMyProfile
Three lines each, and the three lines matter: the generic on get<…> types the response, the
Promise<…> return type is the contract callers compile against, and unwrapping data here means
no caller ever sees an axios envelope. ListEmployeesParams above these functions runs to some
seventy optional fields — every grid filter the employees page can send. How does an object that is
mostly undefined become a sane query string? That is the client's job.
The client: one URL rule and one encoding rule
api/client.ts starts by choosing where requests go:
export const API_BASE_URL = import.meta.env.VITE_API_URL ?? '';
Empty by default — so requests are relative (/api/employees) and hit the same origin, where nginx
proxies them to the backend with no CORS in sight. VITE_API_URL is baked in at image build time;
you only set it for the host-HMR loop (the Frontend reference covers that
workflow).
Then it replaces axios's default query encoding:
export const apiClient = axios.create({
baseURL: API_BASE_URL,
paramsSerializer: (params: Record<string, unknown>) => {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) {
for (const item of v) {
if (typeof item === 'string') sp.append(k, item);
}
} else if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
sp.set(k, String(v));
}
}
return sp.toString();
},
});
Three behaviors, each load-bearing. Arrays append, so sort: ['lastName,asc', 'firstName,asc']
becomes sort=lastName,asc&sort=firstName,asc — the repeated-key form Spring expects for
multi-sort; axios's default sort[]= bracket style would silently not sort. Scalars set. And
anything else — undefined, null, objects — falls through both branches and vanishes, which is
what lets ListEmployeesParams carry seventy optional filters and put only the active ones on the
wire.
Every request carries the token, from outside React
apiClient.interceptors.request.use(config => {
const { token } = store.getState().auth;
if (token) {
config.headers.set('Authorization', `Bearer ${token}`);
}
return config;
});
The Redux store, imported directly — no hook, no context. This is the payoff of keeping auth in Redux (lesson 05 makes the case): a plain module can read the token, so the interceptor works for every caller, React or not.
The Predict: six requests, one expired token
A dashboard mounts and fires six queries at once. The access token expired a minute ago, so all six
come back 401. Predict: how many POST /api/auth/refresh calls leave the browser — and what
happens to the user if your answer is six? Write your answer down before reading on.
The file answers with a comment before it answers with code:
// Refresh tokens are single-use (rotated on every call, with reuse treated as theft -- see
// RefreshTokenService on the backend). If several requests 401 at once, each independently calling
// refresh would present the same token twice and the second call would look like a replay attack,
// logging the user out. Sharing one in-flight promise serializes concurrent refreshes instead.
let refreshPromise: Promise<string | null> | null = null;
const refreshAccessToken = (): Promise<string | null> => {
refreshPromise ??= (async () => {
const { refreshToken } = store.getState().auth;
if (!refreshToken) return null;
try {
const { data } = await axios.post<AuthResponse>(`${API_BASE_URL}/api/auth/refresh`, { refreshToken });
store.dispatch(setTokens({ token: data.accessToken, refreshToken: data.refreshToken }));
return data.accessToken;
} catch {
return null;
}
})().finally(() => { refreshPromise = null; });
return refreshPromise;
};
One call. refreshPromise ??= means the first 401 creates the promise and the other five await
the same one; .finally clears it so the next expiry starts fresh. And the stakes are not
politeness: refresh tokens rotate on every use and the backend treats reuse as theft. Six
independent refreshes would present the same token six times, and call two would log the user out
as a suspected attacker. The naive version is not slower — it is a logout bug.
The response interceptor wires it up:
if (error.response?.status === 401 && config && !isExemptEndpoint) {
if (!config._retried) {
config._retried = true;
const newToken = await refreshAccessToken();
if (newToken) {
config.headers.set('Authorization', `Bearer ${newToken}`);
return apiClient(config);
}
}
// Refresh failed (or a retried request 401'd again) -- the session is unrecoverable.
// Revoke the refresh token server-side rather than leaving it live for its full TTL.
await logoutOnce();
}
_retried caps every request at one retry, so a genuinely-dead session cannot loop. isExemptEndpoint
excludes /login, /refresh, and /logout — the file's comment: they "carry their own credential
(or none at all)", and a 401 from /login "is just 'wrong password'". And logoutOnce is the same
single-flight shape again, this time so six dying requests send one revocation instead of six.
The hook layer: keys, reads, and invalidating writes
hooks/api/useEmployees.ts is where React finally enters, and it is deliberately boring:
export const EMPLOYEES_QUERY_KEY = ['employees'];
export const useEmployee = (id: number | null) =>
useQuery({
queryKey: ['employees', id],
queryFn: () => getEmployee(id as number),
enabled: id !== null,
});
export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EmployeeRequest) => createEmployee(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY });
},
});
};
// … useUpdateEmployee, useArchiveEmployee, useRestoreEmployee: same shape
The key is exported because invalidation is the whole game: every write invalidates
EMPLOYEES_QUERY_KEY, and since React Query matches keys by prefix, ['employees'] wipes
['employees', 5] and ['employees', 'all'] too. Create an employee anywhere and every employee
read in the app refetches. No hand-managed cache, no stale drawer.
Errors follow one convention, from api/errors.ts:
export const getApiErrorMessage = (error: unknown, fallback: string): string => {
if (axios.isAxiosError<ApiErrorResponse>(error) && error.response?.data.message) {
return error.response.data.message;
}
return fallback;
};
Components call it in onError — toaster.create({ title: getApiErrorMessage(error, 'Failed to create employee'), type: 'error' }) is the exact line in the employee form drawer. The server's
message when it sent one, your fallback when it did not, and never a raw axios stack in a toast.
The trace, end to end
A page renders useEmployee(5). React Query misses its cache and calls getEmployee(5), which asks
apiClient for /api/employees/5. The request interceptor reads the token from Redux and stamps the
Bearer header; the serializer has nothing to encode. If the token has expired, the response
interceptor refreshes once — single-flight — and replays. The wrapper unwraps data, React Query
caches it under ['employees', 5], and the component re-renders with a typed EmployeeDto. Later,
useUpdateEmployee succeeds and invalidates ['employees'], so the read runs again. Four files,
each doing one job. For the exhaustive contract — the portal's separate client, endpoint
conventions, fetchPage — hand off to ../frontend/api-layer.md.
Where this shows up in MotorPH
- frontend/src/api/client.ts — the instance, the serializer, both single-flight mechanisms
- frontend/src/api/types.ts — every DTO, one file
- frontend/src/api/employees.ts — the wrapper module to copy
- frontend/src/api/errors.ts —
getApiErrorMessage - frontend/src/hooks/api/useEmployees.ts — the hook module to copy
- docs/frontend/api-layer.md — the reference this lesson decodes
Recap
To add an endpoint the way the other seventy were added: DTO in types.ts, wrapper in
api/<entity>.ts, hook in hooks/api/use<Entity>.ts — and these are the rules each step obeys.
- DTOs go in the one shared
types.ts, because shapes cross module boundaries and 270 importers need exactly one definition. - Wrappers are three typed lines with no React, so hooks, grids, and tests all call the same function.
- Never build your own axios call — only
apiClientcarries the Bearer header, the Spring-compatible array encoding, and the 401 retry. - Token refresh is single-flight because refresh tokens rotate and reuse reads as theft; a second concurrent refresh is a logout.
- Export the query key and invalidate it in every mutation's
onSuccess, so a write anywhere refreshes reads everywhere; surface failures withgetApiErrorMessage(error, fallback).