for resilient React UIs that recover on their own
An error boundary that shows a permanent gray wall after a failure is only half the solution. With resetKeys, retry buttons, and a clean reset mechanism, the fallback becomes a real recovery path instead of a dead end.
Table of Contents
- 1. Why an error boundary alone is not enough
- 2. The resetKeys pattern of react-error-boundary
- 3. A retry button for manual resets
- 4. A custom error boundary class without a library
- 5. Limits: what error boundaries do not catch
- 6. Granularity: where to place boundaries
- 7. Fallback UI and UX considerations
- 8. Reporting errors to monitoring tools like Sentry
- 9. Practical checklist and comparison of approaches
- 10. Summary
- 11. FAQ
1. Why an error boundary alone is not enough
A classic error boundary in React catches rendering errors in its child tree and shows a fallback UI instead of a blank white screen. That is the starting point, but in a production application it is not enough: once the error has occurred, the boundary stays stuck in the error state permanently, because componentDidCatch sets internal state only once and React does not automatically re-render the component afterwards. The user sees an error message and, without a full page reload, has no way of getting back into a working state.
Resilient UIs therefore need two additional capabilities: an explicit way to reset the error state, and a mechanism that resets automatically on certain changes, for example when the route or a key prop changes. Both requirements can be implemented with plain React, but they become much more convenient with the react-error-boundary library by Kent C. Dodds, because it already ships the resetKeys pattern and an onReset callback instead of every team reinventing them for each boundary.
2. The resetKeys pattern of react-error-boundary
The ErrorBoundary component from react-error-boundary accepts a prop called resetKeys: an array of values the boundary compares on every render. If even one of these values changes between two renders, the boundary automatically resets its internal error state and tries to render the children again, without the user having to do anything. This is especially useful when an error is tied to a specific record, for example a product ID on a detail page: once the user navigates to a different product, the old error is no longer relevant and the boundary should stop showing it.
In practice, you typically pass the values the rendered content depends on, such as the product ID from URL parameters or a query key. Important detail: resetKeys compares values shallowly, so primitive values like strings or numbers work reliably, while freshly created objects or arrays count as 'changed' on every render and can trigger unwanted resets. That is why resetKeys should consist of stable, primitive identifiers rather than objects that get re-instantiated on every render.
import { ErrorBoundary } from 'react-error-boundary';
import { useParams } from 'react-router-dom';
function ProductErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="font-semibold text-red-800">Could not load product</p>
<p className="text-sm text-red-700">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-2 rounded bg-red-600 px-3 py-1.5 text-sm text-white"
>
Try again
</button>
</div>
);
}
function ProductDetailPage() {
const { productId } = useParams();
return (
<ErrorBoundary
FallbackComponent={ProductErrorFallback}
resetKeys={[productId]}
>
<ProductDetail productId={productId} />
</ErrorBoundary>
);
}
3. A retry button for manual resets
react-error-boundary passes a function called resetErrorBoundary down to the fallback component. If the user calls it via a button, the boundary resets its internal error state and tries to render the children again, exactly like with resetKeys, only triggered manually instead of automatically. This is the right choice for errors whose cause is not tied to a recognizable prop, such as a temporary network failure while loading a widget, where the user should decide when a new attempt makes sense.
A plain reset of the UI state does not automatically resolve the underlying cause, however: if the component threw an error on first render because of a stale cache or a failed request, a simple remount would reproduce the same error immediately. That is why the retry button is usually combined with an explicit refetch function and a retry counter that changes the fallback UI after a few unsuccessful attempts, for example from 'Try again' to 'Please try again later', so the user is not offered an infinite loop.
import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function RetryFallback({ error, resetErrorBoundary }) {
const [attempts, setAttempts] = useState(0);
const maxAttempts = 3;
const handleRetry = () => {
setAttempts((a) => a + 1);
resetErrorBoundary();
};
return (
<div role="alert" className="rounded-lg border p-4">
<p>{error.message}</p>
{attempts < maxAttempts ? (
<button onClick={handleRetry}>Try again</button>
) : (
<p className="text-sm text-gray-500">
Please reload the page in a few minutes.
</p>
)}
</div>
);
}
function Widget({ refetch }) {
return (
<ErrorBoundary
FallbackComponent={RetryFallback}
onReset={() => refetch()}
>
<DataDrivenWidget />
</ErrorBoundary>
);
}
4. A custom error boundary class without a library
Error boundaries in React can still only be implemented as class components today, because they rely on the lifecycle methods static getDerivedStateFromError and componentDidCatch, for which there is no hook equivalent. getDerivedStateFromError is called synchronously during render and returns the new state that activates the fallback UI, while componentDidCatch handles side effects like logging afterwards. Anyone who does not want to add an extra dependency can implement both methods themselves with very little code.
For resetting without a library, the key remount trick has become the standard approach: you keep a counter in the parent state and pass it as the key to the boundary or its children. When the key changes, React removes the entire instance and creates a new one, which guarantees that the boundary's internal error state disappears as well, without having to implement a dedicated resetError method on the class. The trick is simple but effective because it relies on React's built in reconciliation behavior instead of custom state logic.
import { Component } from 'react';
class SimpleErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('Boundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
function Parent() {
const [resetCount, setResetCount] = useState(0);
return (
<SimpleErrorBoundary
key={resetCount}
fallback={(error) => (
<button onClick={() => setResetCount((c) => c + 1)}>
Reload ({error.message})
</button>
)}
>
<RiskyChild />
</SimpleErrorBoundary>
);
}
5. Limits: what error boundaries do not catch
Error boundaries only catch errors thrown during rendering, in lifecycle methods, or in constructors of components below the boundary. They do not catch errors in event handlers such as onClick, errors in asynchronous code such as setTimeout callbacks or promise chains, errors during server side rendering, or errors that occur inside the boundary itself. A try/catch inside a fetch callback that only writes to the console will therefore never trigger the fallback UI of the surrounding boundary, no matter how the boundary is configured.
To make such asynchronous errors visible through the same boundary anyway, developers use the 'throw during render' trick: you keep the error in state and explicitly throw it on the next render, so React treats it like a regular render error and the next error boundary catches it. react-error-boundary provides the useErrorHandler hook, or a similar pattern in newer versions, for exactly this bridge between imperative async code and declarative error handling.
import { useState, useCallback } from 'react';
function useThrowAsyncError() {
const [, setError] = useState();
return useCallback((error) => {
setError(() => {
throw error;
});
}, []);
}
function AsyncWidget() {
const throwAsyncError = useThrowAsyncError();
useEffect(() => {
fetchData().catch((err) => throwAsyncError(err));
}, [throwAsyncError]);
return <DataView />;
}
6. Granularity: where to place boundaries
A single error boundary around the entire app is quick to set up but has a decisive drawback: an error in one unimportant widget, like a product recommendation, drags the whole page into an error state, even though the rest of the application would work fine. At the other end of the spectrum, a boundary around every single component offers maximum isolation but creates massive boilerplate and lets closely related UI areas fall into error states independently of each other, which confuses users.
In practice a two tier strategy works best: a coarse, application wide boundary at the root as a final safety net, combined with targeted boundaries around self contained feature widgets like a shopping cart, a comment section, or a recommendation carousel. Each of these inner boundaries gets its own resetKeys that match the data context of that widget, so an error in the recommendation widget never brings down the checkout area on the same page.
7. Fallback UI and UX considerations
The fallback UI should clearly communicate to the user whether this is a temporary problem a retry can fix, or a permanent failure where a retry button only creates frustration. Technical details like stack traces do not belong in the visible UI in production, they belong in a separate logging system, while the visible message stays understandable and actionable, for example 'This section could not be loaded' instead of a raw error message from the JavaScript engine.
In terms of layout, the fallback UI should ideally take up the same amount of space as the actual content, so surrounding elements do not jump when the error occurs or gets resolved. For accessibility, a role='alert' on the fallback container is important so screen readers announce the error message automatically, and for critical errors, focus can additionally be moved explicitly to the error message so keyboard users do not miss it.
8. Reporting errors to monitoring tools like Sentry
Besides the error object, componentDidCatch also provides an info object with componentStack, which shows exactly where in the component tree the error occurred, often more valuable for debugging than the raw JavaScript stack trace. This information should be forwarded to a monitoring system such as Sentry, ideally enriched with context like the current resetKeys, the retry count, and relevant IDs, so recurring errors can be traced back to specific records.
react-error-boundary offers the onError prop on the ErrorBoundary component for exactly this, handling reporting in one central place in the code instead of repeating it in every single boundary. It is important to deduplicate repeats: if a user triggers the same error multiple times by clicking retry repeatedly, monitoring should not create a separate, equal weight event every time, but rather track the retry count as an extra field on the original event.
import * as Sentry from '@sentry/react';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<ErrorBoundary
FallbackComponent={AppFallback}
onError={(error, info) => {
Sentry.captureException(error, {
contexts: { react: { componentStack: info.componentStack } },
tags: { boundary: 'app-root' },
});
}}
>
<Router />
</ErrorBoundary>
);
}
9. Practical checklist and comparison of approaches
For small, self contained applications without external dependencies, a hand written boundary with key remount can be enough, since it requires no extra package and the code stays fully under your own control. But once multiple boundaries with different resetKeys, retry counters, and centralized error reporting are needed, react-error-boundary pays off, because it already implements these patterns robustly and well tested instead of every team rebuilding them from scratch.
The table below summarizes which approach fits which use case. As a rule of thumb: automatic reset via resetKeys for data dependent errors, a manual retry button for network related errors, and a combination of both for widgets that depend on external data as well as unstable connections.
| Approach | Automatic reset | Effort | Use case |
|---|---|---|---|
| Custom class without reset | No |
Low | Prototypes, one off fallbacks |
| Custom class with key remount | No, manual via key |
Medium | Small apps without an extra package |
| react-error-boundary without resetKeys | No, retry button only |
Low | Network dependent widgets |
| react-error-boundary with resetKeys | Yes, on prop change |
Low | Detail pages with a changing ID |
| react-error-boundary with onReset and retry counter | Yes, plus limited manual attempts |
Medium | Critical widgets with monitoring |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Error Boundary Retry Patterns: The Essentials at a Glance
resetKeys
Array of values that automatically resets the boundary on change, ideal for data dependent errors like a changing ID.
Retry button
Call resetErrorBoundary manually, combined with a refetch and an attempt counter to avoid infinite loops.
Key remount
Reset a custom boundary without a library by changing the parent's key, relies on React's reconciliation.
Monitoring
Send componentStack from componentDidCatch to Sentry or similar tools, carry the retry count as context.