Files
probo/contrib/claude/error-handling.md
Émile Ré 68338fb4ae Address PR review comments on error boundaries
Guard ErrorBoundary against a falsy thrown value: track a separate
hasError flag and accept the caught value as unknown, so throwing null,
0, or "" still renders the fallback instead of looping back into the
failing subtree.

Add role="alert" to InlineError and ErrorState so assistive tech
announces errors that appear dynamically after a fetch or query failure.

Fix the RecentUpdatesSection doc example, which showed onRetry={reset}
with @throwOnFieldError even though reset cannot clear a field error;
use a reload and point to the retry table.

Signed-off-by: Émile Ré <emile@probo.com>
2026-07-15 11:20:15 +02:00

12 KiB

Error handling (frontend)

Errors must be containable at any level of the tree, not only at the route root. A failure in one section, list, or widget should be able to render a local fallback without taking down the rest of the page. This guide covers the reusable ErrorBoundary, the error/fallback props that let any subtree opt in, and how to handle the errors boundaries cannot catch (async work and event handlers).

Topic Guide
Error/fallback props as configuration contrib/claude/react-components.md
Where *Error files live in the tree contrib/claude/app-arborescence.md
UI for error states (ErrorLayout, …) contrib/claude/ui.md

Two kinds of errors

React error boundaries only catch errors thrown during rendering, in lifecycle methods, and in the constructors of the tree below them. They do not catch:

  • errors in event handlers (onClick, onSubmit, …),
  • errors in async code (await, .then, setTimeout),
  • errors thrown in the boundary itself.

So there are two complementary tools:

  1. ErrorBoundary — for render-time failures (including Relay/Suspense errors thrown while reading data). Place it at the level where you want the blast radius to stop.
  2. try/catch — for event handlers and async work. Surface the result through a toast and/or by storing the error in state.

ErrorBoundary

A single reusable class component (the sanctioned use of a class — see react-components.md) is the only error boundary primitive. It is generic and works at route, section, or component level.

// packages/ui/src/v2/ErrorBoundary/ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";

export interface ErrorBoundaryProps {
  children: ReactNode;
  // A node, or a render function that receives the caught value + a reset fn.
  // `unknown` because anything can be thrown, not just an Error.
  fallback?: ReactNode | ((error: unknown, reset: () => void) => ReactNode);
  onError?: (error: unknown, info: ErrorInfo) => void;
}

interface ErrorBoundaryState {
  // Tracked separately from `error` so a falsy thrown value (null, 0, "") still
  // renders the fallback instead of looping back into the failing subtree.
  hasError: boolean;
  error: unknown;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false, error: null };

  static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: unknown, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

  reset = () => this.setState({ hasError: false, error: null });

  render() {
    if (this.state.hasError) {
      const { fallback } = this.props;
      if (typeof fallback === "function") {
        return fallback(this.state.error, this.reset);
      }
      return fallback ?? null;
    }
    return this.props.children;
  }
}

Trigger at any level

The same boundary wraps a whole route or a single widget — only the placement and the fallback differ.

// Route level — a page's *Error file is the fallback
<ErrorBoundary fallback={<ThirdPartiesPageError />}>
  <ThirdPartiesPage queryRef={queryRef} />
</ErrorBoundary>
// Section level — one failing section, the rest of the page survives
<ErrorBoundary
  fallback={(error, reset) => (
    <RiskSummarySectionError error={error} onRetry={reset} />
  )}
  onError={reportError}
>
  <RiskSummarySectionContent />
</ErrorBoundary>

In a router context, route boundaries are wired through the router's ErrorBoundary slot (e.g. RootErrorBoundary reading useRouteError()); ErrorBoundary above is for in-page boundaries below the route level.

Components expose error/fallback props

Any component that owns a fallible region should let the surrounding subtree decide the fallback, by accepting fallback / onError and wrapping its risky content itself. These are configuration/composition props (a slot + a callback) — they never carry fetched data.

interface RiskSummarySectionProps {
  fallback?: ReactNode;
  onError?: (error: Error) => void;
}

export function RiskSummarySection({ fallback, onError }: RiskSummarySectionProps) {
  return (
    <ErrorBoundary fallback={fallback} onError={onError}>
      <RiskSummarySectionContent />
    </ErrorBoundary>
  );
}

try/catch for events and async work

Boundaries will not catch a rejected promise in a submit handler. Wrap the risky call in try/catch, report via toast, and keep the UI responsive.

// Good — async event handler guards itself; the boundary above can't help here
function PublishButton() {
  const { t } = useTranslation();
  const toast = Toast.useToastManager();
  const [isPublishing, setIsPublishing] = useState(false);

  async function onPublish() {
    setIsPublishing(true);
    try {
      await publishReport();
      toast.add({ title: t("reports.published"), type: "success" });
    } catch (error) {
      toast.add({
        title: t("reports.publishFailed"),
        description: error instanceof Error ? error.message : t("common.unknownError"),
        type: "error",
      });
    } finally {
      setIsPublishing(false);
    }
  }

  return <Button disabled={isPublishing} onClick={onPublish}>{t("reports.publish")}</Button>;
}
// Bad — relying on an ErrorBoundary to catch an async rejection (it never will)
function PublishButton() {
  async function onPublish() {
    await publishReport(); // throws → unhandled rejection, boundary does not fire
  }
  return <Button onClick={onPublish}>Publish</Button>;
}

For Relay mutations, prefer the built-in onCompleted / onError callbacks (see relay.md) over a manual try/catch; use try/catch for non-Relay async work (fetch, parsing, third-party SDKs).

Relay field errors and fragment-level boundaries

A GraphQL response can be partial: data is present but one field carries an error (with a path). To contain such a failure to the component that reads the bad field — instead of collapsing the whole page — two pieces cooperate:

  1. The fetch layer only throws request-level errors. A request-level error has no path (auth, malformed request, transport) and applies to the whole operation, so it throws and propagates to the nearest boundary. Field-level errors (those with a path) are left in the response so Relay can attribute them to the reading field.

    The compliance-portal wires this in its own apps/compliance-portal/src/lib/relay/fetch.ts (it does not use @probo/relay's makeFetchQuery, which throws for the whole operation on any known code).

  2. @throwOnFieldError on the query/fragment that reads the field. With the directive set, a field error throws at the read site (usePreloadedQuery for a query, useFragment for a fragment). Put it on the fragment to isolate a section/row, and on the query to route page-level field errors to the route boundary.

Because useFragment throws in the component body (not in a child), the boundary must be an ancestor. Split the component into a thin wrapper (holds the ErrorBoundary) and a *Content child (reads the fragment):

export function RecentUpdatesSection({ trustCenterKey }: Props) {
  const { t } = useTranslation();
  return (
    <ErrorBoundary
      // This section reads a field of the preloaded page query — there is no
      // local refetch, so recover with a reload, not the boundary's `reset`
      // (see "Retrying: reset vs refetch vs reload" below).
      fallback={(
        <InlineError
          message={t("errors.inline.message")}
          retryLabel={t("errors.inline.retry")}
          onRetry={() => window.location.reload()}
        />
      )}
    >
      <RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />
    </ErrorBoundary>
  );
}

function RecentUpdatesSectionContent({ trustCenterKey }: Props) {
  const data = useFragment(fragment, trustCenterKey); // throws here on a field error
  // ...
}

const fragment = graphql`
  fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError { ... }
`;

The portal ships three fallback tiers, all backed by the same ErrorBoundary:

Tier Placement Fallback
Global root route (RootErrorBoundary) ErrorState full page (standalone)
Page pathless child route inside the layout ErrorState inside the shell (TopBar/footer survive)
Section / row around a fragment-reading subtree InlineError (vertical for sections, horizontal for rows) with a retry

ErrorState and InlineError are presentational v2 kit components (see ui.md); the app maps the caught error to copy/actions and passes them in.

Retrying: reset vs refetch vs reload

A boundary's reset alone does not clear a Relay field error — at any level, not just lists. reset only re-renders the subtree; the read hits the same errored record still cached in the store and throws again. reset is therefore only a real recovery for transient render errors (e.g. a non-Relay render crash). To recover a Relay field error you must go back to the network first, then clear the boundary. Pick the mechanism by what owns the data:

Context Recovery
Route / page boundary window.location.reload() (or router revalidation)
Refetchable list/section (useRefetchableFragment) refetch(..., { fetchPolicy: "network-only" }), then reset the boundary once it settles
Section reading a preloaded query (no local refetch) reload the owning query via the loader's loadQuery(..., { fetchPolicy: "network-only" }), or fall back to window.location.reload()
Transient / non-Relay render error reset

In all the network cases, reset the boundary after the fetch settles (not before), or the remount races the in-flight request straight back into the same error.

ListErrorBoundary encapsulates the refetchable-list case: it owns a reset key and exposes onRetry(done), where the caller (which holds refetch, above the boundary) refetches network-only and passes the onComplete callback as done.

// The page owns refetch; item fragments carry @throwOnFieldError, so a row's
// field error throws below the boundary while refetch survives above it.
<ListErrorBoundary
  onRetry={done => startTransition(() => {
    refetch(variables, { fetchPolicy: "network-only", onComplete: done });
  })}
>
  {rows}
</ListErrorBoundary>

Because useRefetchableFragment throws at its own read site, put @throwOnFieldError on the item fragments (so the throw lands below the boundary), not on the refetchable list fragment (whose read is above it — a whole-connection failure there is a page-level error via @required).

Custom errors for node-type mismatches

When a page fetches node(id:) and the resolved __typename is not the type the view expects, throw a dedicated error, not a bare Error, so the boundary can render the correct state (404):

// Good — a typed error the boundary maps to the not-found page
import { NotFoundError } from "#/lib/relay/errors";

if (data.node?.__typename !== "MailingListUpdate") {
  throw new NotFoundError("Update not found");
}
// Bad — an untyped error the boundary can only show as a generic failure
if (data.node?.__typename !== "MailingListUpdate") {
  throw new Error("Update not found");
}

See relay.md (Node type guards).

Placement guidance

  • Route root — one boundary so an unhandled failure shows a full-page error instead of a blank screen.
  • Section / list / widget — add a boundary around any independently-loaded region (especially Relay Suspense subtrees) so one failure degrades gracefully.
  • Interaction surfaces (dropdowns, dialogs that load data on open) — wrap the lazily-loaded content so opening a broken menu doesn't crash the page.
  • Event/async pathstry/catch + toast, never a boundary.