State & Data Fetching
There is no client-side global store (no Redux, no Zustand). Server state
lives in TanStack Query v5; the little local UI state there is stays in
useState. Forms are React Hook Form + Zod.
The request path
component
→ src/core/hooks/useXService.ts (TanStack Query hook)
→ src/core/api/services/x-service.ts (thin async fn)
→ src/core/api/axios.ts (the one axios client)
→ GET /api/config (once, to resolve baseURL)
→ HLMIS API
The axios client is a singleton with a lazy base URL
ApiClient in src/core/api/axios.ts creates one axios instance. A
request interceptor calls ensureInitialized(), which — the first time
only — fetches /api/config and sets axiosInstance.defaults.baseURL.
After that this.initialized = true and it never re-reads.
/api/config is a Next route handler (src/app/api/config/route.ts) that
returns process.env.API_URL (and Superset settings). Client-side,
ConfigService (src/core/lib/config.ts) caches the result for 5 minutes;
server-side (the NextAuth authorize path) it reads process.env
directly.
Because both ApiClient and ConfigService are module-level singletons in
the running Node process, changing API_URL in .env and relying on
HMR is not enough — the server-side auth path keeps using the old URL
until the dev server is fully restarted (pnpm dev again, not just save).
This has bitten us; if login 401s after an env change, restart the server.
Response envelope
Single items come back as { payload: T }; lists as a
PaginatedResponse<T> ({ data, meta: { total, page, limit, totalPages, hasNext, hasPrevious } }). Service functions unwrap response.data.payload
so hooks and components deal in plain T.
The response interceptor
src/core/api/axios.ts also installs a global error interceptor: it
fires a destructive toast for any error response, and rejects with an
Error carrying .status and .correlationId. Components should not
re-parse error.response.status themselves — the interceptor already
normalised it (this was a cleanup from the engineering audit). Every request
also gets an X-Correlation-Id (generated unless the caller passed one, so
several calls for one user action can share it).
TanStack Query conventions
- Hooks live in
src/core/hooks/useXService.ts, one per resource, and are the only thing components import for server data. Report-page query hooks were pulled out of the page components into here during the audit — don't defineuseQueryinline in a page. - Query keys are declared as a
constobject per hook file (e.g.PRODUCT_QUERY_KEYS), list keys include pagination/filter args:[KEYS.ALL, page, limit, filter]. - Cache config is shared per file as a spread constant — e.g. products
use
staleTime: 30min,gcTime: 24h,refetchOnWindowFocus: false. Pick values to match how often that resource actually changes. - Mutations call
queryClient.invalidateQueriesor an explicitrefetchpassed down from the table component on success. - Manual pagination/filtering — the Mantine tables run
manualPagination+manualFilteringand feedpageIndex/pageSize/ filters back into the query key; the server does the paging.
Forms
React Hook Form with zodResolver. The Zod schema is defined next to the
form. For any screen that also carries custom fields, the base schema is
.merge()-d with one built from the active attribute definitions —
buildAttributeSchema() in src/components/attribute-fields/ — so
validation and typing cover the dynamic fields too. See
Attributes on the Frontend.