Data fetching & admin API errors¶
appQueryClient,processHttpResultvalidation, the admin-API error pipeline driving the global toast and modal submit wrappers.
Hard rules¶
- Single
appQueryClientfrom@bricks-common/bo/core, provided at app root. Don't instantiate a parallel client. - 401:
createAdminAxios({ onUnauthorized })accepts() => void | Promise<void>. Sign out (Better Auth) before redirect. Concurrent 401s share one lock. Mutations drain (60s cap) so in-flight writes finish first.onUnauthorizeditself is capped at 3s — hungsignOutstill redirects to/login. - QueryClient placement follows the router. Projects (TanStack Router): expose it on
__root.tsxviacreateRootRouteWithContextand read it in loaders fromRoute.useRouteContext()— neveruseEffect-fetch on mount. Invest (React Router): keep it onProvidersonly — no loader prefetch. - Defaults:
staleTime: 0,gcTime: 5 days,retry: 0(queries + mutations). Override per call site only when the operation genuinely needs to retry. - Services just throw. No
try/catchon a specific code, no'available' | 'unavailable'discriminated returns, no*_INELIGIBLE_CODEconstants. Let the error propagate; the global handler toasts. - Don't reach into
error.response.data?.message/?.code.useSubmitWithError+buildAdminApiErrorMessagecover every case. - Error code translations live in
api-communication-bricksoffice, not the consumer app.src/translations/fr.jsonundererror-api.<code>. - Request payloads + shared primitives single-sourced in
@bricks-common/api-communication-bricksoffice— the API imports them too. Don't re-declare in a controller or in the app. Stricter server guard → strengthen the shared validator (same output type), don't fork. Status:api-communication-bricksoffice/src/VALIDATOR-MUTUALIZATION.md. Same for admin enum labels consumed by BO and API Slack:as const satisfies Record<Enum, string>next to the enum. - New bricksoffice endpoint bodies: Zod
*BodySchema(z.infer→*Body) so discriminated unions survive.d.tsemit; leave existing idtlt*Payloaduntil touched. Forms stay idtlt (form.mdc). - HTTP client via context.
<HttpClientProvider client={adminAxios}>at the consumer root; services readuseHttpClient(). No axios prop drilling, no module-scope singleton import. - Authenticated file downloads go through the http client, not a bare
<a href>— the attachment sits behind the auth cookie on another origin.httpClient.get<Blob>(path, { responseType: 'blob' }), thentriggerBrowserDownload+extractFilenameFromHeaderfrom@bricks-common/bo/utils: the API names its own attachments and exposesContent-Dispositionfor that, so never rebuild the filename client-side. A blob error body defeatsbuildAdminApiErrorMessage, so such a mutation owns its toast (meta: { silent: true }+notifyError). Canonical:bricksoffice-notaireuseDownloadEcheancierPdf. - Gate project-scoped queries with
enabled, neverskipTokenalone. WithstaleTime: 0, an unconditional fetch on a globally-mounted host refetches on every window focus, so id-gated / always-mounted queries must be gated. ButskipTokendoesn't make a query inactive, andinvalidateProjectScopedQueriesinvalidates every<module>Keys.all(the bare prefix an id-gated hook falls back to when its id is null) — so a loneskipTokenquery gets force-refetched and throws "Attempted to invoke queryFn when set to skipToken". Always pair them:queryFn: id != null ? () => … : skipToken(types the queryFn for the absent-id branch, kills focus-refetch) andenabled: id != null(keeps the disabled query inactive so the broad invalidate skips it). Applies to id-gated hooks (canonicaluseSpvLemonwayBalance) and shared keys alike (projectKeys.list()viauseProjects).
The error pipeline¶
Admin API returns { statusCode, message: "<kebab-code>", … } on every 4xx/5xx — message is the code, not text. Codes translate via adminTranslations['error-api'][code] from @bricks-common/api-communication-bricksoffice; fall back to raw code, then errors.generic.
Wired three ways:
- Mutations toast via the global MutationCache.onError on appQueryClient.
- Queries going through processHttpResult toast the same (default showErrorNotification: true).
- Mutations whose mutationFn unwraps through processHttpResult pass showErrorNotification: false — the MutationCache already toasts, otherwise the same error shows twice.
- Modal submits wrap with withGlobalErrorHandling (default) or useSubmitWithError.
Helpers from @bricks-common/bo/core¶
| Helper | Use for |
|---|---|
appQueryClient |
the single QueryClient |
processHttpResult({ responsePromise, validator }) |
unwrap axios + validate via idtlt or zod (Validator<T> | z.ZodType<T>); toasts on error by default |
withGlobalErrorHandling(asyncFn) |
wrap a modal submit; swallows rejections so success doesn't run on error |
useSubmitWithError() |
hook variant — { handleSubmit, error, clearError }; reach for it for error.code branching or inline <Alert> |
buildAdminApiErrorMessage(error) |
translate axios error → French (used internally by the global handler) |
getAdminApiErrorCode(error) |
raw kebab code (when branching) |
buildAdminApiErrorMessage and getAdminApiErrorCode live in @bricks-common/bo/modules/auth, not in core.
HttpClientProvider / useHttpClient() are at @bricks-common/bo/providers.
Service + mutation pattern¶
// services/useProject.ts
export const useProject = (id: UUID) => {
const httpClient = useHttpClient()
return useQuery({
queryKey: projectsKeys.detail(id),
queryFn: (): Promise<ProjectResponse> =>
processHttpResult({
responsePromise: httpClient.get(getProjectEndpoint.request.path({ id })),
validator: projectResponse,
}),
})
}
// services/queryKeys.ts — centralized per module
export const projectsKeys = {
all: ['projects'] as const,
list: () => [...projectsKeys.all, 'list'] as const,
detail: (id: UUID) => [...projectsKeys.all, 'detail', id] as const,
}
Mutations always invalidate the list on success.
Server lists key on the whole query object: list: (query) => [...keys.all, 'list', query]. Repeated query params (status=a&status=b) are serialized by createAdminAxios (paramsSerializer: { indexes: null }) — never set it per call.
Project-scoped mutations: invalidateProjectScopedQueries¶
Why blanket, not per-id precise? Denormalized fields (hasInternalNote, payment status, balance, overdue counts) surface across 6+ tables. We shipped the same stale-data bug repeatedly when invalidating only the home module. Broad invalidation costs a few refetches; under-invalidating shows wrong data.
import { invalidateProjectScopedQueries } from '@/modules/projects/services/invalidations'
onSuccess: () => invalidateProjectScopedQueries(queryClient)
Adding a new project-touching module → extend the helper's list in invalidations.ts. Don't call extra invalidateQueries at each mutation's call site.
Non-project mutations (useCreatePropertyMonthlyUpdate, useUploadYearlyFinancialUpdate, …) keep narrow scoped invalidations.
Modal submit — withGlobalErrorHandling vs useSubmitWithError¶
// Default
const handleSubmit = withGlobalErrorHandling(async (values) => {
await mutation.mutateAsync(...)
notifySuccess(m['...']())
onClose()
})
// When you need the error locally (inline alert, branch on code)
const { handleSubmit, error, clearError } = useSubmitWithError()
For query-level branching (a query error gates UI like a disabled form), prefer query.isError. Inspect the code only when UX needs to distinguish reasons.
For mutations needing an inline or custom toast, opt out with useMutation({ meta: { silent: true } }). If the mutationFn uses processHttpResult, also pass showErrorNotification: false — otherwise that helper toasts and the hook/onError toasts again.
Adding a new endpoint¶
- Add it in
projects/common/both/api-communication-bricksoffice/src/endpoints/(Zod*BodySchema, not a new idtlt*Payload). - Re-export from the package barrel.
- Add matching
error-api.<code>translations tosrc/translations/fr.json— otherwise the toast falls back to the raw kebab. - Rebuild the package (
pnpm --filter @bricks-common/api-communication-bricksoffice build) before the consumer resolves the new exports.
What NOT to do¶
- ❌
try { ... } catch { /* global handler */ }inline in a modal submit — usewithGlobalErrorHandling. - ❌
try/catchin a service to translate a business code intonull/'unavailable'— let it propagate. - ❌
status >= 400 && < 500catch-all to mean "ineligible" — swallows 401/403/404/422 and hides real failures. - ❌
notifyError/toast.errorfrom a mutationonErroror call-site unlessmeta.silent === true(andshowErrorNotification: falseonprocessHttpResult) —MutationCachealready toasts. - ❌ Hand-list
invalidateQueries({ queryKey: <module>Keys.all })in a project-scoped mutation'sonSuccess— extendinvalidateProjectScopedQueriesinstead.