Foundation — Data Fetching¶
Scope note: Foundation owns the cross-cutting plumbing that every feature module's data fetching plugs into — the axios instance, the idempotency interceptor, the error parser, the QueryClient, and the useIdempotentMutation hook. This doc is the canonical reference that future module plans link back to instead of redocumenting these patterns.
Authoritative cross-cutting source: ../../standards.md § Data Fetching / § Idempotency / § Optimistic Concurrency / § Error Envelope. The sections below describe what foundation builds to deliver those standards.
Stack recap¶
| Concern | Mechanism |
|---|---|
| HTTP client | axios instance at src/lib/axios.ts (single global) |
| Query cache | TanStack Query v5 at src/lib/queryClient.ts (single global) |
| Hook generation | Orval — pnpm orval — output at src/generated/ |
| Mutations | Use Orval hook directly in components; wrap with useIdempotentMutation for any non-GET |
Reads (queries)¶
Use Orval-generated hooks directly in components. No bespoke queryOptions wrapper, no features/*/api/ indirection. Component:
const { data, isLoading, isFetching } = useGetItem(itemId)
if (isLoading) return <Skeleton />
return <ItemDetail item={data} refreshing={isFetching} />
isLoading vs isFetching — strict rule:
isLoading === true→ first fetch, no data yet. Show full skeleton.isFetching === true&data !== undefined→ background refetch on existing data. Show subtle overlay or spinner on the existing rendered data. Never destroy the table/detail and remount a skeleton on a refetch — destroys user's scroll position and selection.
Writes (mutations)¶
Every POST/PUT/PATCH/DELETE carries an Idempotency-Key header (UUIDv4). Foundation wires this in three layers:
Layer 1 — Axios request interceptor (src/lib/axios.ts, GRF-4)¶
The interceptor enforces presence and forwards the value from config.headers['Idempotency-Key']. It does not generate the key — the calling hook does, so retries of the same logical action reuse the same key. If the header is absent on an unsafe method, the interceptor consults src/lib/idempotencyKeyHolder.ts (set by useIdempotentMutation) and writes the value onto the outgoing config. If neither is present, a dev-time warning fires (catches bugs where a caller forgot the wrapper).
Layer 2 — useIdempotentMutation hook (src/hooks/useIdempotentMutation.ts, GRF-14)¶
Wraps any Orval mutation and:
- Generates a single UUIDv4 the first time
mutate/mutateAsyncis called. - Sets the key on
idempotencyKeyHolderimmediately before invoking the underlying mutation; the axios request interceptor consumes it. - Reuses the same key across TanStack Query's automatic retries and across user-triggered retries (e.g. clicking [Save] after a transient network failure).
- Exposes
resetIdempotencyKey()to forge a fresh key. (Notreset()— that name is taken by TanStack Query'sUseMutationResult.reset()which resetsdata/error/status.) - On
IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD, silently mints a fresh key and retries once. A second mismatch propagates to the caller — programmer error.
Layer 3 — withSideEffects helper (src/hooks/withSideEffects.ts, GRF-14)¶
Standardises the success-side wiring — toast + cache invalidation — that every per-module mutation hook needs. Without this layer, each features/<module>/hooks/useFooMutations.ts ends up re-implementing the same onSuccess: () => { toast.success(t(...)); queryClient.invalidateQueries(...) } boilerplate.
// src/hooks/withSideEffects.ts
type SideEffects<TData, TVariables, TContext = unknown> = {
successToast?: string // i18n key, e.g. 'item-catalog:toast.created'
invalidates?: QueryKey[] // invalidated on success
onSuccess?: (data: TData, vars: TVariables, ctx: TContext) => void // escape hatch
}
export function withSideEffects<TData, TVariables, TContext = unknown>(
mutation: UseMutationResult<TData, unknown, TVariables, TContext>,
effects: SideEffects<TData, TVariables, TContext>,
): UseMutationResult<TData, unknown, TVariables, TContext>
Success-only by design. No errorToast field. Error handling stays with handleApiError(e, opts) at the call site (see § Errors below) — it owns inline field errors via the form ref, the onConflict callback, and i18n-resolved toast messages. An errorToast here would either bypass handleApiError (losing field-error inline) or double-toast.
How per-module mutation hooks compose all three layers¶
// features/item-catalog/hooks/useItemMutations.ts
import {
useCreateItem,
useUpdateItem,
getListItemsQueryKey,
getGetItemQueryKey,
getGetItemsStatsQueryKey,
} from '@/generated/item-catalog'
import { useIdempotentMutation } from '@/hooks/useIdempotentMutation'
import { withSideEffects } from '@/hooks/withSideEffects'
export function useItemMutations(itemId?: string) {
return {
create: withSideEffects(useIdempotentMutation(useCreateItem()), {
successToast: 'item-catalog:toast.created',
invalidates: [getListItemsQueryKey(), getGetItemsStatsQueryKey()],
}),
update: withSideEffects(useIdempotentMutation(useUpdateItem()), {
successToast: 'item-catalog:toast.updated',
invalidates: itemId
? [getListItemsQueryKey(), getGetItemQueryKey(itemId)]
: [getListItemsQueryKey()],
}),
}
}
The per-module hook stays a 5-line wrapper — it owns only what's actually per-module (Orval-generated query-key helpers, translation namespace) and delegates the wiring to the shared helper. Query keys come from Orval, never from a hand-rolled itemKeys factory.
What to invalidate, when¶
| Mutation kind | Invalidate |
|---|---|
| Create (POST) | getListXQueryKey() for the resource. If a /stats endpoint exists: also getGetXStatsQueryKey(). |
| Update (PUT/PATCH) | getGetXQueryKey(id) and getListXQueryKey(). List is invalidated because row cells render fields that may have changed. |
| Delete | getListXQueryKey() (and getGetXStatsQueryKey() if exists). Optionally queryClient.removeQueries({ queryKey: getGetXQueryKey(id) }) for instant detail cleanup. |
| Status-change action (activate / deactivate / archive) | Same as update. Stats only if the status affects an aggregated count. |
| Bulk action | Broadest applicable: list + stats. |
Conventions:
- Always pass an Orval-generated query-key helper. Never
['items', 'list']. Hand-built arrays drift the day Orval regenerates with a different shape. - Prefix matching is the default.
invalidateQueries({ queryKey: getListItemsQueryKey() })— no params — invalidates alluseListItemsvariants regardless of filter combo. That's what we want: every list view reflects the new state.{ exact: true }is reserved for the narrow "this one filter combo only" case (rare; flag in review). - Cross-module invalidation is explicit. If a mutation in module A affects queries in module B (creating a manufacturing order ⇒ warehouse stock counts), list module B's Orval keys explicitly in
invalidates. Importing a query-key helper across modules is fine — it's a pure thin function, not a feature-to-feature dependency. - Stats invalidate only on writes.
?page=2does not invalidate stats — the count hasn't changed. - Don't invalidate what the mutation doesn't directly affect. A wildcard
queryClient.invalidateQueries()will refetch every active query in the app and tank UX. Be specific. - Optimistic updates are deferred. When a feature needs one, it uses the
effects.onSuccessescape hatch + Orval'sonMutate/onSettleddirectly. Foundation does not ship a helper for optimistic updates yet.
Why this stratification¶
Three layers, three concerns, each independently testable:
| Layer | Concern | Test surface |
|---|---|---|
useIdempotentMutation |
Per-call key lifecycle (retries reuse, reset mints fresh) | Unit test the hook |
withSideEffects |
Toast + invalidation wiring | Unit test the helper with a mock mutation |
| Per-module hook | Module-specific cache keys + i18n keys | Per-module integration test (lives in the module's first ticket) |
Why not TanStack Query global mutationCache handlers? They're powerful (set meta on a mutation, handle it globally) but hide what's happening from the call site. The explicit withSideEffects(mutation, { ... }) wrapping at the per-module hook makes the side-effects visible without requiring devs to learn a convention buried in a global config.
Errors¶
BE envelope per ../../standards.md § Error Envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [{ "field": "name", "message": "Name must be at least 3 characters" }]
}
}
details is always an array (BE defaults to [] for non-validation errors). The BE message field is always populated.
Error handling is split across two layers. The axios response interceptor handles cross-cutting global side effects; handleApiError(e, opts) at the call site produces the visible UX. No silent paths — every error becomes inline form errors, a toast, or the caller's onConflict callback.
Foundation ships these helpers in src/lib/errors.ts (M3 errors ticket, GRF-9):
| Helper | Purpose |
|---|---|
parseApiError(e: unknown): ApiError \| null |
Narrow unknown (TanStack Query throws unknowns) into a typed ApiError or null. |
isConflictError(e: unknown): boolean |
Returns true for 409 CONCURRENT_MODIFICATION. |
isIdempotencyMismatch(e: unknown): boolean |
Returns true for 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD. |
applyFieldErrorsToForm(form, err) |
Walks details[] and calls RHF's setError(field, { type: 'server', message }) for each. Dev-mode console-warns if a detail.field is not declared on the form. |
resolveErrorMessage(err, ns?) |
i18n resolution: ${ns}:errors.${code} → common:errors.${code} → BE message → errors.unexpected. |
handleApiError(e, opts) |
The one call-site entry point — see contract below. |
Interceptor triage — global side effects only. Never toasts. Always rethrows.¶
| Trigger | Action |
|---|---|
HTTP 401 UNAUTHORIZED |
Redirect-to-login slot (identity ships the actual redirect). Until identity ships, log + rethrow — handleApiError at the call site shows errors.networkError for the rethrown error. |
HTTP 403 FORBIDDEN_INSUFFICIENT_PERMISSION |
Navigate to /forbidden. Rethrow so call sites can also react if they want. |
| Any non-2xx | recordException + setAttributes('http.status_code', 'error.code') on the active OTel span — wired in Foundation M5 once @opentelemetry/api lands. |
| Anything else | Rethrow, untouched. |
handleApiError(e, opts) triage — call-site UX. Always produces visible feedback.¶
| Code | UX |
|---|---|
VALIDATION_ERROR (422) with details[] and opts.form present |
applyFieldErrorsToForm(opts.form, err) — errors render inline at the field. No toast. |
VALIDATION_ERROR (422) no details[] |
Toast with resolved message. |
IDEMPOTENCY_KEY_REQUIRED (422) |
Programmer error — toast common:errors.IDEMPOTENCY_KEY_REQUIRED, do not retry. |
INVALID_SORT_FIELD (422) |
Toasts common:errors.INVALID_SORT_FIELD today. Auto-recovery (strip sort URL param + refetch with default sort) needs router/search-param context the helper doesn't have — wired when the first list page adopts an opts.onInvalidSort?: () => void callback. |
IMMUTABLE_FIELD_CANNOT_BE_CHANGED (400) |
Programmer error from stale Orval output — toast common:errors.IMMUTABLE_FIELD_CANNOT_BE_CHANGED. |
CONCURRENT_MODIFICATION (409) |
Call opts.onConflict?.() — caller wires the refresh-and-retry UX. If absent, toast common:errors.CONCURRENT_MODIFICATION. |
IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD (409) |
useIdempotentMutation resets + retries silently. A second mismatch propagates to the caller — programmer error. |
| Any other code | Resolve via i18n order → toast. BE message is the always-works fallback. |
| Transport / non-API error (network down, JSON parse fail) | Toast common:errors.networkError. |
Example¶
import { handleApiError } from '@/lib/errors'
const form = useForm<ItemCreate>(...)
const { mutateAsync } = useCreateItem()
const onSubmit = form.handleSubmit(async (values) => {
try {
await mutateAsync({ data: values })
} catch (e) {
handleApiError(e, {
form,
toastNs: 'item-catalog',
onConflict: () => setShowConflictBanner(true),
})
}
})
The same handleApiError(e, { form, toastNs }) call handles inline field errors (when BE returns details[]), conflict-banner triggers (when caller provides onConflict), and resolved toasts for everything else.
What lands later¶
- Per-module
<module>:errors.<CODE>entries — opportunistic, added by each feature module's first form that can trigger them. The Polish copy is decided per code during UX testing. - Typed-per-endpoint code narrowing — once an OpenAPI spec ships per-route
errors_for()declarations (BE GRI-87), the planner decides whether the FE adds a typederrorCodesOf(useEditItem)accessor. TodayApiError.code: stringis enough. useApiErrorToast()hook variant — only if a real consumer needs distinct toast styling per error category. Not built yet.
Optimistic locking¶
Every editable aggregate carries a version: number field. PUT/PATCH bodies echo back the last-seen version. A 409 CONCURRENT_MODIFICATION from the BE means someone else modified the record between our read and our write.
UX pattern (no shared component yet — established in foundation, first instantiated by identity's edit-account ticket)¶
- Detect 409 via
isConflictError(error). - Show a non-blocking banner above the form: "Ten rekord został zmieniony przez kogoś innego. Odśwież, aby zobaczyć aktualną wersję." with a
[Odśwież]button. - On
[Odśwież]click → invalidate the read query → form resets to the new server state → user re-applies their changes manually.
This is intentionally a friction-heavy flow rather than an auto-merge. We don't have the domain knowledge in foundation to pick the right field-level merge strategy; that decision lives per-module and may differ for item_catalog vs manufacturing.
List endpoints (pagination, sorting, filtering, search, snapshot)¶
The BE has a comprehensive list-endpoint convention documented in grinsystem-api/docs/architecture/conventions.md § List Endpoints. The FE mirrors it section-for-section in ../../standards.md — that mirroring is the deliverable of a dedicated M0 ticket. The subsections below summarise what every FE list page assumes and what foundation guarantees.
Pagination¶
Request shape — ?page=1&page_size=20 (1-indexed, default 20, BE-enforced max 100). Boundary violations come back as 422.
Response envelope:
type Paginated<T> = {
items: T[]
total: number // count after filters
page: number
page_size: number
pages: number
snapshot_at: string // BE-issued timestamp — see § Snapshot consistency below
}
Sorting¶
Single sort param using JSON:API-style syntax:
?sort=name→ ascending byname?sort=-created_at→ descending bycreated_at- Leading
-means descending. No+prefix. - Multi-sort is deferred BE-side — FE wires single sort only.
- Allowed fields are per-endpoint enums published in OpenAPI. Unknown field → 422
INVALID_SORT_FIELD.
UI convention: TanStack Table column-header click toggles asc → desc → unsorted (back to BE default). No separate "Sort by" dropdown anywhere. The column header is the only way the user sets sort.
Filtering¶
Flat typed scalar query params. No filter[x]= nesting, no RSQL, no JSON-encoded filter blobs. This is a hard constraint because filters must survive Orval codegen as typed useListXxx(params) arguments.
Conventions:
| Concept | Convention |
|---|---|
| Multi-value filter | Repeated scalar params — ?item_type=ingredient&item_type=packaging parses as a list on the BE. |
| Status / lifecycle | Tri-state literal — ?status=active / ?status=inactive / ?status=all. Default = "working set" (active/open). |
| Date range | Two separate inclusive bounds — ?created_after=2026-01-01&created_before=2026-05-01. Either side optional. |
| Combining filters | AND across params. No OR; no nested groups. |
| Operators | eq, in (via repeated params), date-range (_after/_before). No >, <, contains_any yet. |
FE rule: only write a filter to the URL when the user picks a non-default value. Defaults stay implicit so the URL stays scannable.
Search¶
Single ?q=... param (BE max 200 chars). Case + diacritic-insensitive on the BE (Postgres unaccent + ILIKE). Matches a per-resource field allowlist defined on the BE — wire just sends q, server decides which columns to match.
FE convention: debounce the input (300ms feels right; tune per page if needed). Search is a filter, lives in URL alongside everything else, follows the same draft → committed lifecycle as filters.
Snapshot consistency (snapshot_at)¶
When the user pages through results, new rows that arrive between page 1 and page 2 shouldn't shift the row set under their feet (which would skip rows or double-show them). The BE solves this by returning snapshot_at on every list response — a server timestamp that pins the row set. The FE roundtrips it:
| User action | snapshot_at handling |
|---|---|
| Navigate next / prev page | Echo the same snapshot_at in the URL → BE returns rows as of that moment. |
| Change page size | Echo the same snapshot_at. |
| Change a filter, sort, or search query | Drop snapshot_at from the URL → BE re-snapshots with current criteria. |
| Click an explicit "Refresh" button | Drop snapshot_at. |
Open the URL fresh (no snapshot_at in params) |
BE picks now and returns it; FE preserves it from then on. |
This is a per-page concern (the helper that builds the search-param schema must include snapshot_at as an optional ISO 8601 string).
Stats endpoints¶
Some list views need a small set of tenant-wide aggregate counts that are stable across filter changes ("you have 124 items in your catalog — 118 active, 6 inactive") to give the user context. The BE convention is a separate, dedicated endpoint per resource that needs it:
GET /api/v1/{resource}/stats
→ {
"total": 124,
"active": 118,
"inactive": 6,
"by_type": { "ingredient": 80, "product": 38, "packaging": 6 }
}
Not all resources get a /stats endpoint — only ones where the UI benefits from filter-independent counts. FE consumes via a generated useGetItemsStats() hook, cached independently of the list query so it doesn't refetch on every filter change. The list view's "showing X of Y" indicator reads from paginated.total (post-filter); the page-level chrome ("124 items in your catalog") reads from stats.
URL is the source of truth on FE¶
Every piece of list state — page, page_size, sort, filters, q, snapshot_at — lives in TanStack Router search params, validated with a per-page Zod schema via validateSearch. Component state is derived from URL state, never the other way around. This means:
- Deep-linking works for any page state (filtered, sorted, paged into).
- Refresh + browser back/forward preserve everything.
- Foundation doesn't ship a shared search-params builder hook yet — first list-page consumer in
item_catalogis where it lifts out of feature code intocomponents/common/. Until then, each list page composes its own search-params Zod schema following these conventions.
Draft vs committed filter state¶
Foundation establishes the pattern documented in ../../standards.md § Routing: filter UIs maintain a draft state locally (no URL writes, no refetch) until the user clicks [Apply filters], at which point the draft is committed to the URL (which triggers the refetch). This avoids one-letter-at-a-time refetches when a user is composing complex filters. Search (q) is the exception — it commits on debounce, not on a button click.
Shared DataTable deferred¶
TanStack Table is wired against this shape in the first list-page ticket (item_catalog Items list). Foundation does not ship a shared DataTable component yet — pattern is established when there's at least one consumer, to avoid premature abstraction. The first list page in item_catalog is where the shared DataTable lifts out of feature code into components/common/.
Orval¶
orval.config.ts (exists, M0 keeps it untouched) has entries commented out for all seven modules. As each BE spec lands at openapi/<module>.yaml, the matching entry is uncommented and pnpm orval regenerates src/generated/<module>.ts + src/generated/schemas/. Generated code is never edited and is not under test.
Components import generated schemas directly from @/generated/schemas/<module>:
No features/<module>/types.ts re-export barrel. That pattern was dropped from the architecture (see fe-plan.md § Decisions for rationale and the M0 cleanup ticket that updates CLAUDE.md + docs/architecture.md). FE-only schemas/types live next to their consumer or, if reused, in features/<module>/schemas.ts.
What foundation does not build¶
- A shared
DataTablecomponent. Lifts out of the first list-page consumer. - A retry/refresh-and-retry banner component. Lifts out of the first edit-form consumer.
useApiErrorToast()global error handler. Added by the M3 errors ticket as a thin helper — full integration with toast variants lands with the first feature surface that needs distinct error styling.- Auth interceptor body (header is wired, value reads empty string until identity ships).
- A
features/<module>/types.tsre-export barrel. Pattern dropped — components import directly from@/generated/schemas/<module>.