Error Handling
A systematic approach to error handling — component boundaries, form errors, API errors, recovery strategies, and user-facing error UX.
Error handling is one of the most scattered concerns in a codebase. Components handle errors at different boundaries, forms show validation in different patterns, and API errors lack consistent shapes. A systematic approach prevents these inconsistencies and ensures users always get actionable feedback.
Error Taxonomy
Not all errors are the same. Different categories need different handling strategies and different user-facing patterns.
| Category | Source | UX pattern | Severity |
|---|---|---|---|
| Validation | User input, form submission | Inline field error below the input | Recoverable |
| Network | API call, fetch rejection | Toast or inline banner with retry | Recoverable with retry |
| Authentication | Expired session, missing token | Redirect to login with state preservation | Disruptive |
| Authorization | Insufficient permissions | Disabled UI with explanation | Blocking for specific action |
| Server | 5xx, unhandled backend exception | Fallback UI or error page with CTA | Disruptive |
| Not found | Missing resource, broken link | 404 page or inline empty state | Final for that resource |
| Assertion | Unexpected invariant violation | Error boundary fallback | Catastrophic |
The category determines the recovery strategy. A validation error is recoverable in place. A network error may auto-retry. A server error needs explicit user action. Do not handle all errors with the same UI treatment.
Error Boundary Architecture
React error boundaries catch rendering errors at the component level. They should be placed strategically, not wrapped around the entire app.
Where to place boundaries
| Level | Wraps | Fallback behaviour |
|---|---|---|
| Layout | Sidebar, header, page chrome | Keep navigation working; show error in content area |
| Route/page | The page content | Show a page-level error state with retry and a link back |
| Feature section | A dashboard widget or card | Show a widget-level fallback; keep other sections interactive |
| Isolated component | A rich editor or third-party embed | Replace the component with a simplified static version |
<DashboardLayout>
<Sidebar /> {/* No boundary — layout failure should cascade */}
<main>
<ErrorBoundary fallback={<PageError />}> {/* Route-level */}
<ProfileSection /> {/* Feature-level */}
<ErrorBoundary fallback={<WidgetFallback />}>
<ActivityChart /> {/* Isolated */}
</ErrorBoundary>
</ErrorBoundary>
</main>
</DashboardLayout>Error boundary API
React 19's error boundary API supports both class components and a simpler hook-like pattern with useErrorBoundary in libraries like react-error-boundary. The key contract:
interface ErrorFallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
function PageError({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div role="alert" className="flex flex-col items-center gap-4 p-8">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-fd-muted-foreground">
{error.message}
</p>
<button onClick={resetErrorBoundary} className="...">
Try again
</button>
</div>
);
}Do not expose raw error messages to users. Log the full error to the console or monitoring service, and show a user-friendly summary. See Deployment for production monitoring setup.
Error UI Patterns
Each error category maps to a specific UI treatment. Consistency across these patterns is critical for user trust.
| Pattern | When | What to show |
|---|---|---|
| Inline field error | Form validation | Red border on field + message below the label |
| Inline banner | Section-level failure | Coloured banner above the affected content |
| Toast notification | Non-blocking background failure | Dismissible toast in corner |
| Full-page error | Route or page-level failure | Centred message with navigation links |
| Error boundary fallback | Render crash | Component-level fallback or route-level recovery |
| Empty state with message | Search or filter returns no results | "No results" with suggestion to adjust filters |
function FormField({ label, error, children }: FormFieldProps) {
return (
<div>
<label className="...">{label}</label>
{children}
{error && (
<p role="alert" className="mt-1 text-sm text-destructive">
{error}
</p>
)}
</div>
);
}See Forms & Input for form-specific error handling patterns including server action errors and validation strategies.
Retry & Recovery
Every error UI should offer a recovery path. The appropriate retry strategy depends on the error category and the user's context.
| Strategy | When | Implementation |
|---|---|---|
| Manual retry button | Network errors, server errors | Expose resetErrorBoundary or a "Retry" button that re-runs the query |
| Auto-retry with backoff | Idempotent API calls that time out | Exponential backoff (1s, 2s, 4s, 8s cap). Show progress |
| Stale-while-revalidate | Cache-first data that fails to refresh | Show cached data with a banner indicating staleness |
| Form draft recovery | Session expiry during form input | Store form state in localStorage or session storage before submit |
function useDataWithRetry(url: string) {
return useQuery({
queryKey: [url],
queryFn: () => fetch(url).then(res => {
if (!res.ok) throw new Error('Failed to load');
return res.json();
}),
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
});
}Error Contracts
API and server action errors should follow a consistent shape so the UI can handle them uniformly. Define a shared error response type.
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: { code: string; message: string; fields?: Record<string, string> } };
// Usage in a form action
async function createProject(formData: FormData): Promise<ActionResult<Project>> {
const name = formData.get('name');
if (typeof name !== 'string' || name.length < 2) {
return {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Project name must be at least 2 characters.',
fields: { name: 'Must be at least 2 characters.' },
},
};
}
// ... create in database
return { success: true, data: project };
}Error contract rules
- Every error has a
code(machine-readable) and amessage(user-readable). - Field-level validation errors return a
fieldsmap keyed by field name. - Server errors (5xx) return a generic message but log the full details server-side.
- Authentication errors return a specific
AUTH_REQUIREDorSESSION_EXPIREDcode. - Do not expose stack traces, internal IDs, or implementation details in user-facing error messages.
Error Copy Guidelines
Error messages are UX copy. They should inform, reassure, and guide — not blame or confuse.
| Do | Don't |
|---|---|
| Explain what went wrong in plain language | Show "An error occurred" or error codes without explanation |
| Tell the user what to do next | Leave the user guessing about recovery |
| Acknowledge the frustration ("Sorry about that") | Blame the user ("You entered an invalid value") |
| Preserve user input when possible | Clear the form on error |
✗ "Error 500: Internal Server Error"
✓ "We couldn't save your changes right now. Your draft has been preserved. Please try again."
✗ "Invalid input"
✓ "Please enter a valid email address."Error copy should be tested with real users just like primary UX copy. Ambiguous or technical error messages erode trust faster than a broken feature.