This page assumes the boundary setup covered in React Error Boundary Implementation and solves one specific symptom of it: the fallback UI persisting across a navigation that should have cleared it.

The exact failure this page solves #

A route component throws, the nearest error boundary catches it, and the user sees a fallback screen. They click a nav link to a different route — the URL changes, <Routes> matches a new element — but the screen doesn’t change. The fallback UI from the crashed route is still rendered. This happens because a class-based error boundary’s hasError state lives in the boundary instance itself, and React Router swapping the matched element does nothing to that instance unless the boundary sits in a position where the router actually unmounts and remounts it. If the boundary wraps a stable layout, or wraps <Outlet /> without being told the route changed, componentDidCatch set hasError: true once and nothing downstream ever unsets it. The fix ties the boundary’s reset directly to the URL: either force a remount via key={location.pathname}, or use react-error-boundary’s resetKeys prop so the boundary compares the pathname across renders and clears its own error state when it changes.

Zero-to-working implementation #

The component below reads the current route from useLocation and passes it as a reset key to ErrorBoundary. Render it around each routed element, not around the whole app.

// RouteAwareErrorBoundary.tsx
import type { ReactNode } from 'react';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
import { useLocation } from 'react-router-dom';

function RouteFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert" className="route-crash">
      <h2>This page hit an error</h2>
      <pre>{error.message}</pre>
      <button type="button" onClick={resetErrorBoundary}>
        Try again
      </button>
    </div>
  );
}

export function RouteAwareErrorBoundary({ children }: { children: ReactNode }) {
  // useLocation only works below a <Router>, so this component must render
  // inside the router tree, not wrap it.
  const location = useLocation();

  return (
    <ErrorBoundary
      FallbackComponent={RouteFallback}
      resetKeys={[location.pathname]}
      onReset={() => {
        // Clear anything outside React's tree that the crash may have left
        // behind — toasts, focus traps, in-flight abort controllers.
      }}
    >
      {children}
    </ErrorBoundary>
  );
}
// AppRoutes.tsx — layout stays outside the boundary, each route gets its own
import { Routes, Route } from 'react-router-dom';
import { RouteAwareErrorBoundary } from './RouteAwareErrorBoundary';
import { Layout } from './Layout';
import { Dashboard } from './routes/Dashboard';
import { ReportDetail } from './routes/ReportDetail';

export function AppRoutes() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route
          index
          element={
            <RouteAwareErrorBoundary>
              <Dashboard />
            </RouteAwareErrorBoundary>
          }
        />
        <Route
          path="reports/:id"
          element={
            <RouteAwareErrorBoundary>
              <ReportDetail />
            </RouteAwareErrorBoundary>
          }
        />
      </Route>
    </Routes>
  );
}

Step-by-step explanation #

  1. Wrap route content, not the app root. RouteAwareErrorBoundary sits inside each <Route element={...}>, below <Layout />. If the boundary wrapped everything including the nav bar, a reset triggered by navigation would also tear down the chrome the user is currently clicking through.

  2. Call useLocation() inside a component that renders under the router. RouteAwareErrorBoundary is a function component, so useLocation re-evaluates on every render React Router triggers after a URL change, giving the boundary a fresh location.pathname value to compare.

  3. Pass resetKeys={[location.pathname]} to ErrorBoundary. react-error-boundary shallow-compares this array between renders; when an entry changes, it internally calls the same logic as resetErrorBoundary(), clearing hasError and re-rendering children instead of FallbackComponent — without forcing React to unmount and recreate the boundary instance itself.

  4. Use onReset for state outside React’s tree. Timers, AbortControllers, or a toast queue held in a module-level variable won’t be touched by clearing hasError. Clean those up explicitly here so a reset is actually clean.

  5. Keep the fallback’s retry button working two ways. resetErrorBoundary (from FallbackProps) lets the user manually retry without navigating, while the resetKeys change covers the case where they navigate away instead — both paths converge on the same reset logic.

Edge cases #

Scenario Behavior What to do
key={location.pathname} on the boundary Forces React to unmount and remount the entire subtree on every pathname change, even when nothing crashed Reserve this for boundaries wrapping components that hold no valuable local state, or where you specifically want a guaranteed-clean remount instead of a soft reset
resetKeys={[location.pathname]} Clears hasError and re-renders children in place; does not force-unmount unaffected descendants Prefer this as the default — it’s cheaper and preserves scroll position, open menus, and unrelated local state in the same subtree
Shared layout (nav bar, sidebar) inside the boundary Every reset also remounts the nav, resetting its open/closed state, losing focus, restarting entrance animations Always render <Layout /> (or any nav/<Outlet /> wrapper) outside the keyed or reset-tracked boundary — see AppRoutes.tsx above
Search-param-only navigation (e.g. ?page=2) Does not trigger a reset when keyed strictly on location.pathname This is usually correct for pagination/filtering within the same view; add location.search to resetKeys only if distinct query strings represent independently crash-prone data
Using a data router (createBrowserRouter) Each route’s errorElement already catches render/loader errors for that segment and naturally “resets” because navigating re-runs the route’s loader and element Prefer errorElement over a manual boundary at the top of a data-router route; still use resetKeys on any nested boundary further down that same route for finer-grained fallbacks

The distinction that matters most: key change is a demolition — React discards the fiber subtree and builds it fresh — while resetKeys is a targeted flag clear. Reach for resetKeys first; only escalate to key when a component genuinely needs to start from zero.

Two ways to reset a boundary on navigation Flowchart: a fallback shown after a crash, followed by a pathname change, which branches into two outcomes — key={pathname} remounts the entire subtree, while resetKeys=[pathname] performs a soft reset that only clears hasError and preserves unrelated local state. Fallback shown hasError = true User navigates pathname changes key={'{pathname}'} remounts whole subtree resetKeys=[pathname] soft reset: clears hasError only

Verification steps #

  1. Force a crash on one route. Add a temporary throw guarded by a route param, e.g. if (id === 'crash') throw new Error('forced crash'); inside ReportDetail, and navigate to /reports/crash. Confirm the fallback with role="alert" renders.

  2. Navigate away. Click a link to / (or any other route). Assert the fallback disappears and Dashboard renders — no residual crash UI, no manual reload required.

  3. Automated check with React Testing Library. Render AppRoutes inside a MemoryRouter, push it to the crashing path, assert screen.getByRole('alert') exists, then simulate navigation (fire a click on a Link, or call history.push('/') if using a custom history object) and assert screen.queryByRole('alert') is null.

  4. Confirm layout survives the reset. With the nav bar rendered outside the boundary (per AppRoutes.tsx), open a dropdown menu in the nav, trigger a route crash and recovery, and confirm the nav’s open/closed state and any unrelated component state elsewhere in the layout were not reset.

  5. Confirm search-param navigation does not over-reset. Navigate from /reports/1?tab=summary to /reports/1?tab=history (same pathname, different search) and confirm the boundary does not reset if you keyed strictly on location.pathname — the view should just re-render normally without ever having crashed.

Frequently asked questions #

Should I use key={pathname} or resetKeys for this?

Use resetKeys when you want to preserve any state in the subtree that did not cause the crash and just clear the boundary’s hasError flag. Use key={pathname} when you want a guaranteed full remount — for example when the crashed component might hold corrupted refs, subscriptions, or other module-level state that a soft reset can’t reach.

Why does my boundary still show the fallback after I fixed the bug and navigated back?

This almost always means the boundary was keyed on something that doesn’t actually differ between the crash route and the recovery route, or the boundary sits above <Routes> so it never re-renders on navigation. Confirm useLocation is called inside a component that renders under the same Router the boundary lives in, and that resetKeys or key genuinely changes value between the two routes.

Does resetting on pathname change also reset when only the search params change?

No, not if you key strictly on location.pathname. Filtering or paging within the same view via query strings won’t retrigger a reset, which is usually correct since it’s the same logical page. Add location.search to the resetKeys array only if different query strings represent independently crash-prone data sets.