The Data Layer
How data flows from the API to a component: generated client, Result wrapper, per-feature queries, hooks.
Every screen in the Education Hub is, underneath, the same question asked over and over: get me some data from the backend and render it. The data layer is the machinery that answers that question — and the good news is it's one pipeline, used everywhere. Learn it once and every feature in the app suddenly reads the same way.
The trick is that almost none of it is hand-written. The backend publishes an OpenAPI spec, a code generator turns that spec into a typed client, and your job is mostly to wire a feature's query, options, and hook on top. So before we go file by file, here's the whole journey of a single request, start to finish.
The pipeline
Read this top to bottom — it's the path a GET /campuses takes from the server's contract all the way to a rendered component:
backend OpenAPI spec
└─ chowbea-axios generator
└─ src/services/api/_generated (types · operations · contracts)
└─ api.instance (Axios + auth interceptors)
└─ api.client / api.error (Result — never throws)
└─ per-feature BaseQuery + options + hook
└─ componentEach arrow is a layer with one job. The next sections walk them in order, and each has a dedicated sub-page when you want to go deep.
Types from the spec
The bottom of the stack is generated, not authored. The chowbea-axios CLI reads the backend's OpenAPI document and writes three files into src/services/api/_generated — the types, the operation helpers, and the multipart contracts. You never hand-write an API type.
import type { components, operations, paths } from './_generated/api.types'
/** All path templates defined by the OpenAPI paths map. */
type Paths = keyof pathsBecause paths comes straight from the server, the client's get, post, and friends are fully type-checked against real endpoints. Pass a URL the backend doesn't define and TypeScript stops you before the request is ever built.
Never edit _generated by hand
Anything under _generated is overwritten on the next codegen run — your edits vanish. If a type looks wrong, the fix lives in the backend spec, not here. When the API changes, you re-run the generator and the new shapes flow up the whole pipeline for free.
Auth is automatic
The generated client doesn't know about Axios directly — it talks to api.instance, a single shared Axios instance that carries the auth story for you. A request interceptor attaches the token to every outgoing call, so feature code never touches headers:
axiosInstance.interceptors.request.use((config) => {
const { token } = getStoredAuth()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})A matching response interceptor watches for 401s and silently refreshes the token, queuing any concurrent requests while one refresh is in flight so they don't stampede. You almost never think about this — but it's why your queries "just stay logged in." The full mechanics live in The Refresh Queue.
Errors are structural
Here's the idea that surprises people most: at the transport layer, API calls don't throw. The client wraps every request in safeRequest, which converts a thrown Axios error into a plain value — a Result.
export type Result<T> =
| { data: T; error: null }
| { data: null; error: ApiError }So instead of a try/catch, you check a field. A success is { data, error: null }; a failure is { data: null, error } where error is a normalized ApiError — a real message, an HTTP status, a code like NOT_FOUND or VALIDATION_ERROR, plus redacted request context for debugging.
const { data, error } = await api.get('/campuses')
if (error) {
console.error(error.message) // already human-readable
return
}
// here, data is fully typed and non-nullErrors are data, not exceptions
createApiError flattens the dozen ways backends report failures — { message }, { detail }, ASP.NET problem details, validation arrays — into one consistent ApiError. That's why a junior never has to special-case "what shape did this endpoint's error come in?" It's always the same shape. The full story is in The Result Pattern.
One feature, four files
The Result wrapper is great for transport — but components want React Query, not raw { data, error }. That bridge is the per-feature layer, and every feature in src/queries follows the same four-part shape. Using campus as the model:
campus.query.ts— a class extendingBaseQuerythat calls the client and unwrapsResult, throwing aQueryErroron failure so React Query can catch it.campus.options.ts—queryOptionsfactories that bind query keys andqueryFns.use-campus.tsx— the hooks a component actually imports (useCampuses,useCampus).interfaces/— feature-local types, re-exported from the generatedcomponents.
BaseQuery is where transport's "never throw" meets React Query's "throw to signal failure" — it deliberately re-throws so a failed query lands in your error UI:
export class QueryError extends Error {
readonly code: string
readonly status: number | null
// built from an ApiError so global handlers can read status/code
}Once you can name those four files, you can find your way around any feature — they're all cut from this stencil. We walk the whole campus flow, component included, in A Feature End to End.
Caching is declarative
The last layer is React Query, and the thing that makes it predictable is the query key. A key is just an array — ["campus", id] — and it's the single source of truth for both caching and invalidation. Fetch with a key, and a later mutation can invalidate that exact key to force a refresh. No manual cache surgery, no event buses.
That's why the options factory, not the component, owns the key: it keeps every read and every invalidation pointed at the same string. Keys & Caching covers how keys are structured and the invalidation conventions that keep the UI fresh.
The five takeaways
If you remember nothing else about the data layer, remember these:
- Types flow up from the spec — never hand-write API types; re-run codegen instead.
- Auth is automatic — interceptors attach and refresh tokens; feature code ignores it.
- Errors are structural — the transport layer returns a
Result, it doesn't throw. - Caching is declarative — query keys drive both reads and invalidation.
- Every feature is the same four files — query, options, hook, interfaces.
Where to go next
The Generated Client
What chowbea-axios produces, and how the typed client uses it.
The Result Pattern
Why API calls return a data-or-error Result and never throw at transport.
A Feature End to End
The four-file pattern walked from query to rendered component.
Keys & Caching
How query keys drive caching and invalidation across the app.
Realtime Invalidation
Refresh these same keys when someone else — or Rover — writes.
Using Chowbea
When a type is not a REST DTO — export it on the type bus instead.
How realtime works
Centrifugo, the two tokens, and the channel registry behind it all.