Skip to main content

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​

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 apiClient carries 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 with getApiErrorMessage(error, fallback).

Next: 05 — Three kinds of state, on purpose.