Data fetching & admin API errors¶
appQueryClient,processHttpResultvalidation, the admin-API error pipeline driving the global toast and modal submit wrappers. Keep in sync: changingappQueryClientdefaults,MutationCache.onError,processHttpResult,withGlobalErrorHandling,useSubmitWithError, or the error-code flow → update this file in the same PR.
Hard rules¶
- Single
appQueryClientfrom@bricks-common/bo/core, provided at app root. Don't instantiate a parallel client. - Router context exposes the QueryClient.
__root.tsxusescreateRootRouteWithContext<{ queryClient: QueryClient }>. Loader prefetch reads it fromRoute.useRouteContext()— neveruseEffect-fetch on mount. - 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 (BRI-782). 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. - HTTP client via context.
<HttpClientProvider client={adminAxios}>at the consumer root; services readuseHttpClient(). No axios prop drilling, no module-scope singleton import. - 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).
- 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; 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) |
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.
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 error instead of a toast, opt out with useMutation({ meta: { silent: true } }) and render from error.message.
Adding a new endpoint¶
- Add it in
projects/common/both/api-communication-bricksoffice/src/endpoints/. - 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(buildAdminApiErrorMessage(error))from a mutation catch — global handler already toasts; you'd double-toast. - ❌ Hand-list
invalidateQueries({ queryKey: <module>Keys.all })in a project-scoped mutation'sonSuccess— extendinvalidateProjectScopedQueriesinstead.