Add the frontend guides the v2 UI kit and compliance-portal need but that the first rework left uncovered: forms, routing, client state, and permission-gated UI. forms.md documents a tiered approach on Base UI Field/Form -- native constraints, then a validate function, then zod parsed in onSubmit, and react-hook-form only for large or dynamic forms -- and drops the custom useFormWithSchema wrapper. routing.md covers @probo/routes, navigation, typed params, URL-as-state, redirects, auth/protected routes, and the folded-in no-outlet-context rule. state-management.md gives a decision order across Relay, URL, local state, context, and zustand. permissions.md gates UI on the canUpdate/canDelete permission(action:) fields without re-encoding authorization in the client. Rename v2-colors.md to v2-tokens.md and add the typography, radius, shadow, and native-spacing scales alongside color. Extend ui.md with user feedback, empty-state, and accessibility sections; standardize toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an Intl formatting section to i18n.md and a non-Relay HTTP / file upload-download section to ts-style.md. Update the AGENTS.md index and the v2-color-scale cursor rule for the new and renamed guides. Signed-off-by: Émile Ré <emile@probo.com>
6.2 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).
Related guides
| 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:
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.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 error + a reset fn.
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (error) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(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).
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
Suspensesubtrees) 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 paths —
try/catch+ toast, never a boundary.