ADR-0021: Failures are shown, not guarded — failed is a render state, boundaries catch what escapes
- Status: Accepted
- Date: 2026-09-13
- Scope: core (every frontend: web SPAs and mobile)
Context
When a client gets no data back, it can go wrong in three ways, and Expenses shipped all three.
- Showing the missing data as an answer. A refused or failed request leaves
dataundefined.
A screen that falls back todata ?? []then shows its empty state, so “we could not load your
expenses” reads as “you have no expenses”. The mobile app had done this since its first build:
nothing reported a failed query, so every failure looked like an empty account. A default value
does the same thing to a form. A Settings screen rendered with no data shows the defaults as if
the user had saved them. - One throw takes down the whole app. When a render error reaches the root and no boundary
catches it, React unmounts the entire tree. Inexpenses-webv2.41.0 a page mounted behind the
legal-document gate had its/settingsrequest refused. The currency came back empty and
Intl.NumberFormatthrew on it. With no boundary anywhere, the user got a blank white page, and
the gate that would have explained the refusal never painted. - Patching only the throw you found. The obvious fix for the blank page was a null check at
theIntlcall, and atry/catcharound the next one. The operator rejected that: “if
something is broken in the api, we should fix it.” A guard covers only the throw someone thought
of, and it covers it by making a failure look like a normal render. The user sees something
wrong, and nobody ever gets a report about the broken endpoint.
The server side already holds the same line. A null check on a non-nullable field is a smell
(0003-ddd-intent-methods), and the catch-all handler is for server faults only
(0002-clean-architecture-and-use-cases, 2026-09-10 amendment §3).
Decision
We show a failure to the user and never smooth it over. We add no null check, optional chain,
fallback value or try/catch whose job is to make a failed load render like a successful one.
Four mechanisms replace those guards, and each covers a different kind of failure.
- Failed is a render state, handled once per page. A page (or screen) branches once on the
query it is built from, in this order: loading, then failed, then content. The failed branch
renders anErrorStatewith a retry. Everything below the branch can assume the data is
present and needs no defensive checks.ErrorStateis notEmptyState. One says “we could not load this”, the other “there is
nothing here”. A failed query must never fall through to the empty state.- A page built from several queries fails only on the ones it is built from. When a
secondary query fails (a projection, an optional panel), the page keeps showing the facts it
does have. The failure still reaches the user through rule 3. The page’s hook names those
queries explicitly and exposesisErrorandretry.
- Error boundaries catch render errors, at two levels.
- Around the routed page, inside the app shell, so a broken page leaves the navigation
usable. Key the boundary by route, so a caught error does not follow the user to the next page.
On retry, also reset the failed queries, so the remount refetches instead of throwing again
(TanStack’sQueryErrorResetBoundary). - At the root, outside the router, for what the page boundary cannot reach: the layouts and
the router itself. Its action is a reload. The tree that failed holds the app’s state, and
rendering it again would most likely fail the same way. - A fallback renders outside the subtree it guards, so it can only use context from
providers above the boundary. Place the root boundary inside the theme and i18n providers,
or give its fallback no dependency on them. A fallback that throws has nothing left to catch it. - A boundary logs what it catches. A contained error has still happened.
- Around the routed page, inside the app shell, so a broken page leaves the navigation
- One global handler reports every failed query. It subscribes to the query cache and shows a
toast when a query settles in error. It is mounted once, so no page can forget to report. It is
throttled, because one refused page usually means several refused requests at once. It stays
silent only for refusals that another piece of UI already explains, and it lists those by name.
A boundary never sees an async failure and this handler never sees a render error, which is why
both are needed. - A mutation’s failure belongs to its caller. The caller awaits the mutation (
mutateAsync,
0013-centralized-api-layer-and-server-state) and shows the error itself. The global handler
covers queries only.
Consequences
- A broken endpoint becomes visible as a toast plus either an error state or a contained page.
Before, it produced a screen that looked plausible and was wrong. Visibility is the goal, and it
is also how regressions get reported. - Trade-off: if a page’s primary query fails, the error state replaces the whole page, even
where part of it could have been drawn. We accept that for the data a page is built from. For
secondary data, rule 1’s narrowing keeps the page up. - Every page needs an explicit failed branch, and every page built from several queries must decide
which ones it depends on. Both are small changes and easy to see in review. - A throw inside an event handler or a promise never reaches a boundary. Only rule 3 (a failed
query) or rule 4 (a mutation) catches it. A barethrowin a click handler still goes unreported. - Nothing enforces the branch-once shape mechanically. A new page can still write
data ?? []and
fall through to its empty state. Review, including the page scaffold, is the control.
How Expenses applies it
| Client | Page boundary | Root boundary | Failed state | Query-error handler |
|---|---|---|---|---|
expenses-web | PageErrorBoundary around the <Outlet/> in AppLayout (and around the legal gate), keyed by pathname, inside QueryErrorResetBoundary | RootErrorBoundary in main.tsx, inside the providers, outside the router; action is a reload | components/ErrorState | GlobalQueryErrorHandler, mounted in AppLayout (authenticated pages) |
expenses-app | Expo Router’s per-route ErrorBoundary export: AppErrorBoundary, re-exported from app/(app)/_layout.tsx | RootErrorFallback, exported from app/_layout.tsx (the auth screens, the router, the root layout itself); provider-free, see below | organisms/ErrorState | organisms/GlobalQueryErrorHandler, mounted in app/_layout.tsx above the stack, so the auth screens are covered too |
expenses-admin | none | none | page-local styled text, no retry | none |
-
Both handlers show at most one toast every 5 s. Both stay silent only for
TERMS_ACCEPTANCE_REQUIREDandPRIVACY_ACKNOWLEDGEMENT_REQUIRED, which the legal-document gate
explains. A gate code that a build is too old to know still produces a toast. -
In development, mobile’s
AppErrorBoundaryrenders expo-router’s own error screen, which shows
the stack. Release builds renderErrorStatewith a retry. -
Mobile’s root fallback uses no context at all — rule 2’s placement clause, applied. Expo
Router renders a layout’sErrorBoundaryin place of that layout, and the root layout is the
one mountingThemeProvider, so a themed fallback there would throw (styled-components’
useTheme()requires a provider) and leave the blank screen it exists to prevent.
RootErrorFallbackis therefore plain React Native primitives, the theme objects imported
directly (following the system colour scheme) and the i18n instance rather than a hook. The
(app)boundary sits inside the providers and usesErrorState. -
Both clients apply rule 1 to Categories, Payment Methods, Import Templates, Subscriptions,
Incomes, Budgets, Expenses (both tabs), Invoices, Settings, Profile and the three Overview tabs.
Overview shows rule 1’s narrowing:- This Month fails only on the balance and the cash flow, so a failed projection leaves the
month’s actuals readable. - Insights fails on its month summary and its multi-month series.
- Trends fails on its multi-month series.
Both clients’ Assistant memory screens follow the rule too.
- This Month fails only on the balance and the cash flow, so a failed projection leaves the
Known gaps.
expenses-admindoes not follow this ADR yet. It has no boundary at any level and no global
query-error handler. Each page renders its own styled “load failed” text, with no retry.
UserDetailandUserPlanfold a failure intoisError || !data. A render error anywhere in the
console still leaves a blank page.
Related
- 0013-centralized-api-layer-and-server-state — the mutation convention rule 4 relies on
- 0004-component-state-hygiene-and-shared-form-logic — keying the boundary by route is the same
“remount, don’t reset” move - 0003-ddd-intent-methods, 0002-clean-architecture-and-use-cases — the server-side half of
the same stance - 0023-legal-documents-terms-accepted-policy-acknowledged (Expenses) — the gate behind the
v2.41.0 incident, and the two refusals the handlers leave to it - Code:
docs/private/reference-implementations.md, Client error handling