Error Handling Patterns

Handle runtime, network, and rendering failures consistently.

  • Use route-level and component-level error boundaries.
  • Normalize API error shapes before UI rendering.
  • Separate user-facing messages from internal diagnostics.

Alternatives and when to choose them

  • Global-only error boundary for very small apps.
  • Module-specific boundary hierarchy for complex products.

Implementation checklist

  • Define common UI states: loading, empty, error, retry.
  • Map backend error codes to user-safe messages.
  • Capture frontend errors in monitoring with route context.

Practical examples

Route-level error boundary (React Router)

import { isRouteErrorResponse, useRouteError } from "react-router";

export default function ProjectPage() {
  return <h1>Project details</h1>;
}

export function ErrorBoundary() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return (
      <section role="alert">
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>Could not load this project route.</p>
      </section>
    );
  }

  const message = error instanceof Error ? error.message : "Unexpected error";

  return (
    <section role="alert">
      <h1>Could not load this project</h1>
      <p>{message}</p>
    </section>
  );
}

Component-level error boundary (widget isolation)

import { ErrorBoundary } from "react-error-boundary";

export function ProjectSidebar() {
  return (
    <ErrorBoundary
      fallbackRender={({ resetErrorBoundary }) => (
        <div role="alert">
          <p>Sidebar failed to render.</p>
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
    >
      <ProjectStatsWidget />
    </ErrorBoundary>
  );
}

Data fetching errors with useQuery

import { useQuery } from "@tanstack/react-query";

function ProjectDetails({ projectId }: { projectId: string }) {
  const query = useQuery({
    queryKey: ["project", projectId],
    queryFn: () => api.projects.getById(projectId),
    retry: 1,
  });

  if (query.isLoading) return <p>Loading...</p>;
  if (query.isError) {
    return (
      <div role="alert">
        <p>Failed to load project.</p>
        <button onClick={() => query.refetch()}>Retry</button>
      </div>
    );
  }

  return <ProjectView project={query.data} />;
}

Mutation errors with useMutation

import { useMutation, useQueryClient } from "@tanstack/react-query";

type ApiError = {
  status?: number;
  message?: string;
};

function RenameProjectForm({ projectId }: { projectId: string }) {
  const queryClient = useQueryClient();

  const getUserMessage = (error: ApiError) => {
    switch (error.status) {
      case 400:
        return "Invalid project name.";
      case 401:
      case 403:
        return "You do not have permission to rename this project.";
      case 409:
        return "A project with this name already exists.";
      case 422:
        return "Validation failed. Check the input and try again.";
      default:
        return "Could not save changes. Please try again.";
    }
  };

  const mutation = useMutation({
    mutationFn: (name: string) => api.projects.rename(projectId, { name }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["project", projectId] });
    },
  });

  const errorMessage = mutation.isError
    ? getUserMessage(mutation.error as ApiError)
    : null;

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const name = new FormData(e.currentTarget).get("name") as string;
        mutation.mutate(name);
      }}
    >
      <input name="name" />
      <button disabled={mutation.isPending} type="submit">
        Save
      </button>
      {errorMessage ? <p role="alert">{errorMessage}</p> : null}
    </form>
  );
}

Common pitfalls

  • Showing raw backend errors to end users.
  • Unhandled promise errors in async UI actions.

On this page