Design Engineering
Build

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.

CategorySourceUX patternSeverity
ValidationUser input, form submissionInline field error below the inputRecoverable
NetworkAPI call, fetch rejectionToast or inline banner with retryRecoverable with retry
AuthenticationExpired session, missing tokenRedirect to login with state preservationDisruptive
AuthorizationInsufficient permissionsDisabled UI with explanationBlocking for specific action
Server5xx, unhandled backend exceptionFallback UI or error page with CTADisruptive
Not foundMissing resource, broken link404 page or inline empty stateFinal for that resource
AssertionUnexpected invariant violationError boundary fallbackCatastrophic

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

LevelWrapsFallback behaviour
LayoutSidebar, header, page chromeKeep navigation working; show error in content area
Route/pageThe page contentShow a page-level error state with retry and a link back
Feature sectionA dashboard widget or cardShow a widget-level fallback; keep other sections interactive
Isolated componentA rich editor or third-party embedReplace the component with a simplified static version
Error boundary placement example
<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.

PatternWhenWhat to show
Inline field errorForm validationRed border on field + message below the label
Inline bannerSection-level failureColoured banner above the affected content
Toast notificationNon-blocking background failureDismissible toast in corner
Full-page errorRoute or page-level failureCentred message with navigation links
Error boundary fallbackRender crashComponent-level fallback or route-level recovery
Empty state with messageSearch or filter returns no results"No results" with suggestion to adjust filters
Inline field error pattern
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.

StrategyWhenImplementation
Manual retry buttonNetwork errors, server errorsExpose resetErrorBoundary or a "Retry" button that re-runs the query
Auto-retry with backoffIdempotent API calls that time outExponential backoff (1s, 2s, 4s, 8s cap). Show progress
Stale-while-revalidateCache-first data that fails to refreshShow cached data with a banner indicating staleness
Form draft recoverySession expiry during form inputStore form state in localStorage or session storage before submit
Retry pattern with TanStack Query
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.

Server action error contract
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 a message (user-readable).
  • Field-level validation errors return a fields map keyed by field name.
  • Server errors (5xx) return a generic message but log the full details server-side.
  • Authentication errors return a specific AUTH_REQUIRED or SESSION_EXPIRED code.
  • 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.

DoDon't
Explain what went wrong in plain languageShow "An error occurred" or error codes without explanation
Tell the user what to do nextLeave the user guessing about recovery
Acknowledge the frustration ("Sorry about that")Blame the user ("You entered an invalid value")
Preserve user input when possibleClear the form on error
Good vs. bad error copy
✗ "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.

On this page