API Layer
Coreola’s API layer is RTK Query slices on top of a shared base query. The shared base query handles authentication, request tracking, centralized error notifications, and pagination normalization. Each slice declares endpoints and tags.
- Source:
src/api/<resource>/,src/helpers/baseQuery.ts - Library:
@reduxjs/toolkit/query/react
The shared base query
src/helpers/baseQuery.ts exposes getBaseQuery(path, targetAPI) — a factory that returns an RTK Query BaseQueryFn. Every API slice in Coreola uses it.
const getBaseQuery = (path: string, targetAPI = 'SYSTEM_API'): AppBaseQuery => { ... };What it does
- Resolves the base URL —
settings.API[targetAPI] + path, wheretargetAPIis either'SYSTEM_API'or'MOCKUP_API'. The URLs come from.env.local. - Sets
Content-Type: application/jsonand the bearer token from the user slice. - Tracks in-flight requests — dispatches
incrementActiveRequestsCountbefore,decrementActiveRequestsCountafter. Components can subscribe togetActiveRequestsCountto show a global loader. - Surfaces errors as snackbars — on
error.status !== 401, callssnackActions.error(message)with a translated phrase. On401, dispatchessetCheckTime()to trigger token re-validation. - Normalizes pagination for the mock backend — if the response is an array, wraps it in
{ items, pagination }usingX-Total-Countand the_page/_limitquery parameters.
The pagination normalization is only useful with json-server. Real APIs typically already return { items, pagination } (or similar). Once you migrate to a real backend, that block becomes a no-op for non-array responses.
Two API targets
Coreola supports running against two backends simultaneously:
| Key | .env.local | Used by |
|---|---|---|
SYSTEM_API | VITE_APP_SYSTEM_API_URL | Production endpoints |
MOCKUP_API | VITE_APP_MOCKUP_API_URL | Mock endpoints (json-server) |
Each API slice picks its target via the second argument to getBaseQuery:
baseQuery: getBaseQuery('/assessments', 'MOCKUP_API'),In a fresh checkout, every slice points to MOCKUP_API. Migrating a feature to a real backend is change the targetAPI for that one slice — no other code changes needed.
See Environment for the variable reference.
Anatomy of an API slice
A typical slice in src/api/<resource>/:
<resource>/
├─ <resource>.api.ts # createApi — endpoints, tags, transformResponse
├─ <resource>.hooks.ts # Mapped wrappers, derived hooks
└─ <resource>.types.ts # Request and response types<resource>.api.ts
createApi({
reducerPath: 'assessmentsApi',
baseQuery: getBaseQuery('/assessments', 'MOCKUP_API'),
tagTypes: ['Assessment', 'Findings', 'Evidence'],
endpoints: (builder) => ({
getAssessments: builder.query<AssessmentsResponse, AssessmentsPayload>({
query: (params) => ({ url: '', params }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Assessment' as const, id })),
{ type: 'Assessment', id: 'LIST' }]
: [{ type: 'Assessment', id: 'LIST' }],
}),
updateAssessment: builder.mutation<Assessment, UpdateAssessmentInput>({
query: ({ id, ...patch }) => ({ url: `/${id}`, method: 'PUT', body: patch }),
invalidatesTags: (_res, _err, { id }) => [{ type: 'Assessment', id }],
}),
// ...
}),
})Each endpoint is either a query or a mutation. Queries cache by argument; mutations invalidate tags so dependent queries refetch.
<resource>.hooks.ts
The Mapped variants normalize the server shape:
export const useGetAssessmentsQueryMapped = (params, options) => {
const result = useGetAssessmentsQuery(params, options);
return useMemo(
() => ({ ...result, items: mapAssessments(result.data?.items) }),
[result],
);
};Views always import Mapped hooks. The raw ones exist for cases where the un-normalized shape is what you want.
<resource>.types.ts
Type definitions for inputs, outputs, and the mapped UI shapes. Keep these next to the slice — they read together.
Centralized error handling
baseQuery is where 90% of error handling lives. The flow on a failed mutation:
- RTK Query receives the error response.
baseQuerycheckserror.status:- 401 → dispatches
setCheckTime()— the user-slice middleware re-validates the token and may force re-auth. - Anything else → reads
error.data(orerror.errororerror.statusas a string), runs it throughphrase(i18n.t('error', ...)), and callssnackActions.error(translated).
- 401 → dispatches
- The mutation’s promise rejects with the error; the caller can choose to handle it.
This means feature code does not need to write try/catch for the snackbar. The base query already showed it. Feature code uses try/catch only for follow-up logic (e.g., resetting a form after a successful save).
For form-level errors (server validation per field), the response is mapped via useFieldsFromError — see Forms.
Active-requests tracking
Every base-query call increments the counter, every response decrements it. Components that need a “something is happening” indicator subscribe to getActiveRequestsCount:
const count = useSelector(getActiveRequestsCount);
return count > 0 ? <LinearProgress /> : null;The default Coreola layout does not render a global top-of-page progress bar — per-component skeletons are preferred. The counter is still available if you want a global one in a fork.
Pagination normalization
For MOCKUP_API calls that return arrays, baseQuery rewrites the response:
// Input from json-server
[ {...}, {...}, {...} ] // plus X-Total-Count header
// Output exposed to RTK Query
{
items: [ {...}, {...}, {...} ],
pagination: { page, limit, pages, total, items },
}The pagination metadata is what useServerPaginatedTable consumes. This is why the table works seamlessly against json-server even though json-server itself does not understand pagination — the base query translates.
A real backend should return the same shape directly. Set SYSTEM_API on a slice and the array-wrapping branch falls through (non-array responses pass through unchanged).
Choosing endpoint signatures
Conventions across the slices:
- Query payloads are objects, not positional args, so future fields are additive.
- Mutations take the full new shape (or a patch with
id), not URL params. transformResponseis used only when the server shape is structurally different from what UI needs. Cosmetic mapping (renaming a field, computing a derived value) goes in theMappedhook so the cache stays in the raw shape.- Tags use a
type + idpair for granular invalidation; a list tag usesid: 'LIST'so list queries can be invalidated without touching detail queries.
Adding a new endpoint
To an existing slice:
- Add the endpoint to
endpoints: (builder) => ({ ... }). - Declare
providesTags/invalidatesTagsso dependent queries refetch. - If the server shape differs from the UI shape, add a
Mappedwrapper in<resource>.hooks.ts. - Done — RTK Query generates the hook automatically (
useNewEndpointQuery/useNewEndpointMutation).
To add a whole new slice, see State Management → Adding an RTK Query slice.
Anti-patterns
- Hand-rolling
fetchcalls inside a model hook. Use RTK Query — caching, dedup, and the snackbar wiring come for free. - Showing your own error snackbar for an RTK Query error.
baseQueryalready did. Adding a second one is duplication. - Skipping
invalidatesTags. The cache will be stale after the mutation; users see old data until a hard refresh. - Reading the raw response inside the view. Use
Mappedhooks. Views should not know about server shape. - Hard-coding URLs in
query: { url: 'http://...' }. Always go throughtargetAPIso.env.localcontrols routing.
Next steps
- Mock Backend — what
MOCKUP_APIpoints at and how to extend it - State Management — where RTK Query slices fit in the store
- Forms —
useFieldsFromErrorfor per-field server errors