Resetting React Error Boundaries on Route Change

After an error boundary catches a rendering error, navigating to a new route via React Router or Next.js leaves the boundary stuck in its fallback state — the new page never renders. This is a common trap in SPAs and is part of the broader challenge of implementing React error boundaries for production, which itself sits within the wider topic of JavaScript error handling boundaries.

Error boundary on route change: stuck state vs. reset via resetKeys Two-path comparison. Left path (wrong): user navigates, URL changes, boundary remains in error state, fallback still shown. Right path (correct): boundary receives new resetKey from location, resets its state, children remount with new route content. Without reset (WRONG) With resetKeys (CORRECT) User navigates URL updates hasError: true (state unchanged) Fallback still rendered no resetKey User navigates location.key changes resetKeys triggers hasError → false Children remount new route renders

Symptom / Trigger

A user lands on /dashboard, something inside the route throws during render, and the error boundary catches it showing a “Something went wrong” fallback. The user then clicks a nav link to /settings. The URL in the address bar updates correctly, but the page content never changes — the fallback UI from the previous route is still displayed.

There is no error in the console at this point. From React’s perspective nothing new has gone wrong; the boundary is simply still in its error state. The visible symptom looks like this:

# Browser address bar shows: http://localhost:3000/settings
# Page still shows:

Something went wrong.
[Try again]

# Expected: the Settings page content

If DevTools is open, React’s component tree still shows ErrorBoundary with hasError: true wrapping children that now correspond to the new route — but those children never get a chance to render.

Root Cause Explanation

React error boundaries are class components that hold state. When hasError is true, the component renders its fallback instead of this.props.children. React Router performs client-side navigation by swapping out the children passed to whichever components wrap <Routes> — it does not unmount and remount those wrapper components. The boundary sits at the same position in the component tree before and after navigation, so React reconciles it as the same component instance and its state is preserved.

Here is the minimal broken pattern:

// App.jsx — BROKEN: boundary wraps Routes with no reset mechanism
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

export default function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary>   {/* same instance survives every navigation */}
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

Every time the user navigates, <ErrorBoundary> receives new children (the new route component), but its state.hasError flag is still true, so it renders the fallback regardless.

Step-by-Step Fix

1. Use react-error-boundary with resetKeys (React Router v6)

The react-error-boundary library’s <ErrorBoundary> component accepts a resetKeys prop — an array of values that, when any element changes between renders, automatically resets the boundary’s error state.

Pass the current location’s key (a string React Router regenerates on every navigation, including same-path navigations) as a reset key:

// App.jsx — FIXED with react-error-boundary + React Router v6
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';

function FallbackComponent({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{ color: 'red' }}>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function AppRoutes() {
  const location = useLocation();

  return (
    <ErrorBoundary
      FallbackComponent={FallbackComponent}
      resetKeys={[location.key]}
    >
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </ErrorBoundary>
  );
}

export default function App() {
  return (
    <BrowserRouter>
      <Nav />
      <AppRoutes />
    </BrowserRouter>
  );
}

useLocation must be called inside <BrowserRouter> (or another router provider), which is why the boundary and routes are extracted into AppRoutes. Every time the user navigates, location.key changes, react-error-boundary detects the change in resetKeys, and internally calls setState({ hasError: false }) before the new children render.

2. Roll your own class-based boundary with componentDidUpdate

If you cannot use react-error-boundary, add componentDidUpdate to detect when the location key prop changes and reset state manually:

// ClassErrorBoundary.jsx — FIXED class-based approach
import { Component } from 'react';
import { useLocation } from 'react-router-dom';

class RawErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidUpdate(prevProps) {
    // locationKey is injected by the wrapper below
    if (this.state.hasError && prevProps.locationKey !== this.props.locationKey) {
      this.setState({ hasError: false, error: null });
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div role="alert">
          <p>Something went wrong: {this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Retry
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Functional wrapper to inject the location key as a prop
export function ErrorBoundary({ children }) {
  const location = useLocation();
  return (
    <RawErrorBoundary locationKey={location.key}>
      {children}
    </RawErrorBoundary>
  );
}

The componentDidUpdate guard checks this.state.hasError first so the reset only fires when actually in an error state — avoiding unnecessary re-renders on every navigation.

3. Next.js: key prop or router events

In Next.js Pages Router, the equivalent of location.key is router.asPath. The simplest approach is to give the error boundary a key prop equal to the current path, which forces React to unmount and remount the boundary on every route change:

// pages/_app.jsx — Next.js Pages Router
import { useRouter } from 'next/router';
import { ErrorBoundary } from 'react-error-boundary';

function FallbackComponent({ error }) {
  return <div role="alert">Error: {error.message}</div>;
}

export default function MyApp({ Component, pageProps }) {
  const router = useRouter();

  return (
    // key forces full unmount/remount on path change
    <ErrorBoundary key={router.asPath} FallbackComponent={FallbackComponent}>
      <Component {...pageProps} />
    </ErrorBoundary>
  );
}

For the Next.js App Router, place an error.tsx boundary file in each route segment directory. The App Router unmounts and remounts these segment-level boundaries automatically on navigation, so the stuck-state problem does not occur — but you should still scope the boundary to the affected segment rather than wrapping the entire layout, so navigation always remains functional.

4. Scope the boundary below the navigation

Regardless of the reset mechanism used, placing a single boundary around <Routes> means a crash inside one route also catches the nav bar and any surrounding layout. A better pattern scopes the boundary inside each route so the shell of the app stays interactive:

// AppRoutes.jsx — boundary scoped per route, nav survives errors
import { Routes, Route, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';

function RouteErrorFallback({ error, resetErrorBoundary }) {
  return (
    <main role="alert" style={{ padding: '2rem' }}>
      <h2>This page encountered an error</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Reload section</button>
    </main>
  );
}

function BoundedRoute({ element }) {
  const location = useLocation();
  return (
    <ErrorBoundary
      FallbackComponent={RouteErrorFallback}
      resetKeys={[location.key]}
    >
      {element}
    </ErrorBoundary>
  );
}

export function AppRoutes() {
  return (
    <Routes>
      <Route path="/dashboard" element={<BoundedRoute element={<Dashboard />} />} />
      <Route path="/settings" element={<BoundedRoute element={<Settings />} />} />
    </Routes>
  );
}

With this pattern, each route wraps its own content in a boundary that resets on navigation. The nav bar and layout live outside all boundaries and are never affected by a page-level crash.

Verification

Use React Testing Library with MemoryRouter to simulate an error, navigate away, and assert the boundary has cleared:

// AppRoutes.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route, Link } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { useState } from 'react';

// A component that throws once on first render, then works
function Crasher() {
  const [thrown, setThrown] = useState(false);
  if (!thrown) {
    setThrown(true);
    throw new Error('boom');
  }
  return <p>Dashboard loaded</p>;
}

function SafePage() {
  return <p>Settings page</p>;
}

function Fallback({ resetErrorBoundary }) {
  return (
    <div>
      <p>Caught error</p>
      <button onClick={resetErrorBoundary}>Reset</button>
    </div>
  );
}

function TestApp() {
  // MemoryRouter does not expose useLocation at this level,
  // so BoundedRoute pattern is used directly
  return (
    <MemoryRouter initialEntries={['/dashboard']}>
      <nav>
        <Link to="/dashboard">Dashboard</Link>
        <Link to="/settings">Settings</Link>
      </nav>
      <Routes>
        <Route
          path="/dashboard"
          element={
            <ErrorBoundary FallbackComponent={Fallback}>
              <Crasher />
            </ErrorBoundary>
          }
        />
        <Route path="/settings" element={<SafePage />} />
      </Routes>
    </MemoryRouter>
  );
}

test('navigating to a new route after an error shows the new route content', async () => {
  const user = userEvent.setup();
  render(<TestApp />);

  // Boundary should have caught the crash
  expect(screen.getByText('Caught error')).toBeInTheDocument();

  // Navigate to settings
  await user.click(screen.getByRole('link', { name: 'Settings' }));

  // Settings page renders — boundary on /dashboard is no longer in the tree
  expect(screen.getByText('Settings page')).toBeInTheDocument();
  expect(screen.queryByText('Caught error')).not.toBeInTheDocument();
});

When the boundary is scoped per route (step 4), navigation unmounts the errored boundary entirely, so the resetKeys approach is only needed when a single boundary wraps multiple routes. The test above verifies the per-route scoping strategy. To test the resetKeys approach specifically, keep a single boundary around <Routes> in the test and verify location.key is passed correctly.

Edge Cases & Gotchas

  • Same-route re-navigation does not change location.key. If the user clicks the same link twice in a row (same path, same key), resetKeys will not trigger. If you need reset on same-path navigation, consider also including location.pathname in the resetKeys array — though be aware this means any search param or hash change will also reset the boundary.

  • Modal routes and parallel route segments. Some applications overlay modals as routes without unmounting the background page. If the background page’s boundary is in an error state and a modal route opens, the location.key changes but the background boundary — which is still mounted — will reset unexpectedly. Scope boundaries tightly to the content region rather than the entire page layout to avoid this.

  • MemoryRouter in tests. React Testing Library uses MemoryRouter by default when you need router context. MemoryRouter does generate new location.key values on navigation, so the resetKeys approach works correctly in tests. However, if you use createMemoryRouter from React Router v6.4+, pass the router to RouterProvider and access location via useLocation inside the provider.

  • Pending async requests during reset. When resetKeys triggers a boundary reset, any in-flight fetch or useEffect cleanup from the previous errored render may still be pending. If the boundary reset triggers a remount before a stale response arrives, a state-update-on-unmounted-component warning can surface. Use AbortController in your data-fetching effects and cancel on cleanup to avoid this race.

FAQ

Should I use the key prop on the boundary component instead of resetKeys? Using key={location.key} on <ErrorBoundary> forces React to unmount and remount the entire boundary and its subtree on every navigation. This is simpler but more expensive — all child components lose their state and local effects re-run. resetKeys only resets the boundary’s internal error state, leaving child component state intact. Prefer resetKeys for performance; use the key prop when you genuinely want a full remount (for example in Next.js Pages Router where key on the app-level boundary is the idiomatic solution).

Does this work the same way in the Next.js App Router as in the Pages Router? No. The App Router uses file-based error.tsx segments that are automatically scoped to their route segment. The framework unmounts and remounts these boundaries during navigation, so the stuck-state problem does not arise in the same way. You only need the resetKeys/location.key technique in the Pages Router (via _app.jsx) or when you place a boundary manually outside the segment hierarchy. In the App Router, focus instead on placing error.tsx at the right segment depth so that navigation outside the errored segment always works.

What happens to pending server requests when the boundary resets? Resetting the boundary via resetKeys or componentDidUpdate only changes React component state — it does not cancel network requests that were initiated before the error. Any fetch calls or data-loading library requests that started before the crash may still complete and attempt to update state in components that are now remounting. Always pair boundary reset with proper cleanup: use AbortController signals in useEffect, or ensure your data-fetching library (React Query, SWR, etc.) cancels queries when components unmount.