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.
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),resetKeyswill not trigger. If you need reset on same-path navigation, consider also includinglocation.pathnamein theresetKeysarray — 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.keychanges 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. -
MemoryRouterin tests. React Testing Library usesMemoryRouterby default when you need router context.MemoryRouterdoes generate newlocation.keyvalues on navigation, so theresetKeysapproach works correctly in tests. However, if you usecreateMemoryRouterfrom React Router v6.4+, pass the router toRouterProviderand access location viauseLocationinside the provider. -
Pending async requests during reset. When
resetKeystriggers a boundary reset, any in-flightfetchoruseEffectcleanup 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. UseAbortControllerin 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.