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 data undefined.
    A screen that falls back to data ?? [] 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. In expenses-web v2.41.0 a page mounted behind the
    legal-document gate had its /settings request refused. The currency came back empty and
    Intl.NumberFormat threw 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
    the Intl call, and a try/catch around 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.

  1. 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 an ErrorState with a retry. Everything below the branch can assume the data is
    present and needs no defensive checks.
    • ErrorState is not EmptyState. 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 exposes isError and retry.
  2. 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’s QueryErrorResetBoundary).
    • 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.
  3. 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.
  4. 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 bare throw in 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

ClientPage boundaryRoot boundaryFailed stateQuery-error handler
expenses-webPageErrorBoundary around the <Outlet/> in AppLayout (and around the legal gate), keyed by pathname, inside QueryErrorResetBoundaryRootErrorBoundary in main.tsx, inside the providers, outside the router; action is a reloadcomponents/ErrorStateGlobalQueryErrorHandler, mounted in AppLayout (authenticated pages)
expenses-appExpo Router’s per-route ErrorBoundary export: AppErrorBoundary, re-exported from app/(app)/_layout.tsxRootErrorFallback, exported from app/_layout.tsx (the auth screens, the router, the root layout itself); provider-free, see beloworganisms/ErrorStateorganisms/GlobalQueryErrorHandler, mounted in app/_layout.tsx above the stack, so the auth screens are covered too
expenses-adminnonenonepage-local styled text, no retrynone
  • Both handlers show at most one toast every 5 s. Both stay silent only for
    TERMS_ACCEPTANCE_REQUIRED and PRIVACY_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 AppErrorBoundary renders expo-router’s own error screen, which shows
    the stack. Release builds render ErrorState with a retry.

  • Mobile’s root fallback uses no context at all — rule 2’s placement clause, applied. Expo
    Router renders a layout’s ErrorBoundary in place of that layout, and the root layout is the
    one mounting ThemeProvider, so a themed fallback there would throw (styled-components’
    useTheme() requires a provider) and leave the blank screen it exists to prevent.
    RootErrorFallback is 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 uses ErrorState.

  • 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.

Known gaps.

  • expenses-admin does 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.
    UserDetail and UserPlan fold a failure into isError || !data. A render error anywhere in the
    console still leaves a blank page.