Skip to main content

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.

The dev-server gotcha

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 define useQuery inline in a page.
  • Query keys are declared as a const object 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.invalidateQueries or an explicit refetch passed down from the table component on success.
  • Manual pagination/filtering — the Mantine tables run manualPagination + manualFiltering and feed pageIndex/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.