Skip to main content

Using Error Boundaries with Hooks (Part 2) #142

📖 Introduction

In Using Error Boundaries with Hooks (Part 1), we established that while Error Boundaries must be class components, we can effectively use them with functional components by wrapping them or by using libraries like react-error-boundary. This second part dives deeper into advanced usage with react-error-boundary, focusing on the useErrorHandler hook for programmatically triggering error boundaries and discussing more complex patterns and comparisons.


📚 Prerequisites

Before we begin, ensure you have:

  • Familiarity with react-error-boundary's <ErrorBoundary> component (covered in Part 1).
  • Strong understanding of React Hooks, especially useEffect and custom Hooks.
  • Knowledge of asynchronous operations (e.g., fetch API, Promises).

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • The useErrorHandler Hook: What it is and why it's useful.
  • Triggering Error Boundaries Programmatically: Handling errors from async operations or imperative logic.
  • Practical Example: useErrorHandler with fetch: Catching API errors and delegating to an Error Boundary.
  • Higher-Order Component (HOC) Approach: An alternative pattern with withErrorBoundary.
  • Comparing Approaches: When to use <ErrorBoundary> component vs. useErrorHandler vs. withErrorBoundary.
  • Best Practices for Error Handling in a Hooks World.

🧠 Section 1: The useErrorHandler Hook from react-error-boundary

The <ErrorBoundary> component from react-error-boundary is excellent for catching errors that occur during the React rendering lifecycle within its children. However, what about errors that happen outside of rendering? For example:

  • Errors in asynchronous callbacks (e.g., .catch() block of a fetch Promise).
  • Errors in event handlers that you want to escalate to an Error Boundary.
  • Errors in useEffect cleanup functions or other imperative code.

Standard JavaScript throw error in these places won't be caught by React's rendering lifecycle error handling. This is where the useErrorHandler hook comes in.

What is useErrorHandler? useErrorHandler is a custom hook provided by react-error-boundary. It gives you a function that, when called with an error object, will propagate that error to the nearest Error Boundary, effectively triggering its fallback UI.

Signature:

import { useErrorHandler } from 'react-error-boundary';

// Inside your functional component:
const handleError = useErrorHandler();
// handleError is now a function you can call with an error object.

If you call handleError(someErrorObject), react-error-boundary ensures this error is thrown in a way that React's error handling mechanism (and thus the nearest <ErrorBoundary> component from the library or a compatible one) can catch it.

You can also pass a second argument to useErrorHandler which is an error object. If this error object is not null, useErrorHandler will immediately throw it. This is useful if an error occurs during the render phase itself.

const [errorForBoundary, setErrorForBoundary] = useState(null);
useErrorHandler(errorForBoundary); // If errorForBoundary is set, it's thrown to the boundary.

💻 Section 2: Triggering Error Boundaries Programmatically

Let's see how useErrorHandler allows us to send errors from non-rendering contexts to our Error Boundary.

2.1 - Example: useErrorHandler with an Asynchronous fetch Call

Imagine a component that fetches data. If the fetch call fails, we want to display a user-friendly error message using our Error Boundary's fallback UI.

// src/components/DataFetcher.jsx
import React, { useState, useEffect } from 'react';
import { useErrorHandler } from 'react-error-boundary'; // Import the hook

const DataFetcher = ({ url }) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const handleError = useErrorHandler(); // Get the error handling function

useEffect(() => {
let isMounted = true; // Prevent state update on unmounted component
setIsLoading(true);
setData(null); // Clear previous data

fetch(url)
.then(response => {
if (!response.ok) {
// Create an error object for non-ok responses
throw new Error(`Network response was not ok: ${response.status} ${response.statusText}`);
}
return response.json();
})
.then(jsonData => {
if (isMounted) {
setData(jsonData);
}
})
.catch(error => {
// If an error occurs (network error or thrown above),
// pass it to handleError to be caught by the ErrorBoundary
if (isMounted) {
handleError(error);
}
})
.finally(() => {
if (isMounted) {
setIsLoading(false);
}
});

return () => { isMounted = false; };
}, [url, handleError]); // Include handleError in dependency array (it's stable)

if (isLoading) {
return <p>Loading data from {url}...</p>;
}

// This component doesn't render its own error state for the fetch.
// It delegates that to the ErrorBoundary via handleError.
// If data is null and not loading, it means either no fetch yet or an error was handled by boundary.

return (
<div>
<h3>Data Loaded:</h3>
<pre>{data ? JSON.stringify(data, null, 2) : "No data (or error occurred)."}</pre>
</div>
);
};

export default DataFetcher;

Using it with an <ErrorBoundary>:

// src/AppWithAsyncError.jsx
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import DataFetcher from './components/DataFetcher';

// Fallback UI (same as in Part 1)
function ErrorFallbackUI({ error, resetErrorBoundary }) {
return (
<div role="alert" style={{ padding: '20px', border: '1px solid red', margin: '10px', backgroundColor: '#ffe0e0' }}>
<h3>Network or Data Error:</h3>
<pre style={{ color: 'red' }}>{error.message}</pre>
<button onClick={resetErrorBoundary} style={{ padding: '8px 15px' }}>
Try Fetching Again
</button>
</div>
);
}

const myErrorHandlerLogger = (error, info) => {
console.error("App Log (from onError prop):", error, info.componentStack);
};

function AppWithAsyncError() {
// A valid URL for testing success: 'https://jsonplaceholder.typicode.com/todos/1'
// An invalid URL to test error: 'https://jsonplaceholder.typicode.com/nonexistent'
const [dataUrl, setDataUrl] = React.useState('https://jsonplaceholder.typicode.com/todos/1');
const [key, setKey] = React.useState(0); // For resetting via resetKeys

const handleReset = () => {
// Potentially change URL or simply re-trigger fetch by changing key
setKey(prevKey => prevKey + 1);
// If URL caused the error, you might want to prompt user or try a default
// setDataUrl('https://jsonplaceholder.typicode.com/todos/1');
};

return (
<div style={{ padding: '20px' }}>
<h1>Data Fetching with `useErrorHandler`</h1>
<button onClick={() => setDataUrl('https://jsonplaceholder.typicode.com/todos/1')}>Load Valid Data</button>
<button onClick={() => setDataUrl('https://jsonplaceholder.typicode.com/nonexistent')} style={{marginLeft: '10px'}}>Load Invalid Data (to trigger error)</button>

<ErrorBoundary
FallbackComponent={ErrorFallbackUI}
onReset={handleReset}
resetKeys={[dataUrl, key]} // Reset if URL changes or key changes
onError={myErrorHandlerLogger}
>
<DataFetcher url={dataUrl} />
</ErrorBoundary>
</div>
);
}

export default AppWithAsyncError;

Behavior:

  1. DataFetcher mounts and useEffect runs.
  2. fetch(url) is called.
  3. If the url is invalid or a network error occurs, the .catch(error => ...) block is executed.
  4. Inside .catch(), handleError(error) is called.
  5. The useErrorHandler hook (via handleError) effectively "re-throws" this error in a way that the parent <ErrorBoundary> component catches it.
  6. The ErrorBoundary then renders ErrorFallbackUI, displaying the network error message.
  7. Clicking "Try Fetching Again" in the fallback calls resetErrorBoundary, which in turn calls handleReset in AppWithAsyncError.
  8. handleReset changes key (and potentially dataUrl). Since key is in resetKeys, the ErrorBoundary resets, and DataFetcher re-renders, attempting the fetch again with the (potentially new) dataUrl.

This pattern is extremely useful for centralizing UI error display logic while keeping data fetching components cleaner, as they don't need to manage their own specific error UI states if a global fallback is acceptable.


🛠️ Section 3: The withErrorBoundary HOC

react-error-boundary also provides a Higher-Order Component (HOC) called withErrorBoundary. HOCs are an older pattern in React but still useful in some contexts. withErrorBoundary wraps your component and effectively places an <ErrorBoundary> around it.

import React from 'react';
import { withErrorBoundary } from 'react-error-boundary';
import FunctionalWidget from './components/FunctionalWidget'; // Our widget from Part 1

function ErrorFallbackForHOC({ error, resetErrorBoundary }) {
// Same fallback UI component as before
return (
<div role="alert" style={{ padding: '10px', border: '1px solid purple', margin: '10px', backgroundColor: '#f0e0ff' }}>
<h3>Error in HOC-Wrapped Component:</h3>
<pre style={{ color: 'purple' }}>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try HOC Again</button>
</div>
);
}

const myHOCErrorHandlerLogger = (error, info) => {
console.error("HOC Log:", error, info.componentStack);
};

// Wrap FunctionalWidget with the HOC
const ProtectedFunctionalWidget = withErrorBoundary(FunctionalWidget, {
FallbackComponent: ErrorFallbackForHOC,
onError: myHOCErrorHandlerLogger,
// onReset, resetKeys can also be passed here
});

function AppWithHOC() {
const [widgetKey, setWidgetKey] = React.useState(0);

const handleHOCReset = () => {
setWidgetKey(k => k + 1);
};

// To make the HOC resettable via its onReset, we need to pass onReset to it.
// The HOC's options are static, so resetKeys from parent state isn't direct.
// Instead, we can control the key of the HOC-wrapped component.

return (
<div style={{ padding: '20px' }}>
<h1>Using `withErrorBoundary` HOC</h1>
<ProtectedFunctionalWidget
key={widgetKey} // Changing key will remount, effectively resetting
title={`HOC Widget (key: ${widgetKey})`}
// To use onReset from HOC options, the HOC itself would need to accept it
// or be re-instantiated with new options, which is less common.
// The simplest way to reset a HOC-wrapped component is often by changing its key.
/>
<button onClick={handleHOCReset} style={{marginTop: '10px'}}>Reset HOC Widget via Key</button>
</div>
);
}

// If ProtectedFunctionalWidget was defined inside AppWithHOC,
// it could pick up onReset from AppWithHOC's scope for its options.
// Example:
// const ProtectedFunctionalWidget = withErrorBoundary(FunctionalWidget, {
// FallbackComponent: ErrorFallbackForHOC,
// onError: myHOCErrorHandlerLogger,
// onReset: () => setWidgetKey(k => k + 1) // If AppWithHOC state is accessible
// });


export default AppWithHOC;

Usage:

  • withErrorBoundary(YourComponent, ErrorBoundaryOptions):
    • YourComponent: The component you want to protect.
    • ErrorBoundaryOptions: An object containing props for the underlying <ErrorBoundary> (e.g., FallbackComponent, onError, onReset, resetKeys).

Resetting with HOCs: Resetting an HOC-wrapped component where the HOC itself defines the resetKeys or onReset can be a bit trickier if those depend on parent state that wasn't available when the HOC was defined.

  • Changing the key prop on the ProtectedFunctionalWidget (as shown in AppWithHOC) is a reliable way to force a remount and thus a reset of both the component and its HOC-provided error boundary instance.
  • If the HOC is defined within a component where it can access parent state setters for its onReset option, that's another way.

While HOCs are less common with the prevalence of Hooks, withErrorBoundary can be useful for applying error handling to existing class components or in situations where a HOC pattern is preferred.


🔬 Section 4: Comparing Approaches

Let's summarize the three main ways to use react-error-boundary (or similar logic):

  1. <ErrorBoundary> Component:

    • Pros: Declarative, easy to place in JSX, good for wrapping sections of UI, resetKeys is very convenient.
    • Cons: Doesn't directly help with errors outside React's rendering (e.g., in async callbacks) unless those errors are then used to set state that causes a render error.
    • Best for: General UI protection, wrapping routes, widgets, or any component tree where errors might occur during rendering.
  2. useErrorHandler() Hook:

    • Pros: Allows programmatic error throwing to an Error Boundary from anywhere in a functional component (event handlers, useEffect, async callbacks). Integrates error handling for non-render-cycle errors into the Error Boundary system.
    • Cons: Requires an <ErrorBoundary> component to be an ancestor in the tree to catch the thrown error.
    • Best for: Handling errors from async operations (like data fetching), complex event handlers, or custom hook logic where you want a UI fallback rather than just console logging.
  3. withErrorBoundary HOC:

    • Pros: Can be a concise way to wrap components, especially if you have many instances that need the same error handling configuration. Can be useful for class components if you prefer HOCs.
    • Cons: HOCs are a slightly older pattern; Hooks are generally preferred for new code. Resetting can be less direct than with resetKeys on the component.
    • Best for: Applying consistent error handling to multiple components, or when working in codebases that already use HOCs extensively.

General Recommendation:

  • Start by strategically placing <ErrorBoundary> components around major UI sections, routes, and risky components.
  • Use useErrorHandler() inside functional components to propagate errors from async logic or event handlers to those boundaries when a UI fallback is desired.
  • Consider withErrorBoundary if it fits an existing HOC pattern in your project or if you need to protect many class components uniformly.

Often, you'll use a combination: <ErrorBoundary> for declarative wrapping and useErrorHandler for imperative error escalation.


✨ Section 5: Best Practices for Error Handling in a Hooks World

  • Use react-error-boundary: It simplifies many aspects compared to rolling your own full-featured class component, especially the reset logic and Hooks integration.
  • Local try...catch for Local Recovery: For errors in event handlers or async code where you can recover locally (e.g., show a small toast notification, retry an operation silently), use standard try...catch or Promise .catch(). Don't escalate every error to an Error Boundary if a local fix is better UX.
  • Escalate with useErrorHandler for UI Fallbacks: If an error in async code or an event handler means a significant part of the UI cannot function correctly, use useErrorHandler to trigger the nearest Error Boundary's fallback.
  • Combine with Data Fetching Libraries: Libraries like React Query or SWR have their own error handling mechanisms. You can often integrate these with useErrorHandler if you want their errors to also be caught by your UI Error Boundaries.
    // const { data, error } = useQuery(...);
    // useErrorHandler(error); // If 'error' is set by useQuery, throw it to boundary
  • Clear onError Logging: Ensure your onError prop on <ErrorBoundary> (or in componentDidCatch) logs sufficient context to your reporting service.
  • User-Friendly Fallbacks: Always design fallbacks from the user's perspective. What do they need to see? Can they try again?

💡 Conclusion & Key Takeaways (Part 2)

react-error-boundary significantly enhances our ability to manage errors gracefully in functional components. The useErrorHandler hook is a powerful tool for programmatically triggering error boundaries from outside the rendering lifecycle, ensuring that errors from asynchronous operations or complex event handlers can still result in a user-friendly fallback UI. Combined with the declarative <ErrorBoundary> component and the withErrorBoundary HOC, you have a comprehensive toolkit for building resilient React applications.

Key Takeaways:

  • useErrorHandler allows functional components to send errors from async code or event handlers to an Error Boundary.
  • withErrorBoundary HOC offers another way to wrap components for error protection.
  • Choose the approach (<ErrorBoundary>, useErrorHandler, withErrorBoundary) that best fits the specific error handling scenario.
  • Always prioritize clear, actionable fallback UIs and robust error logging.

➡️ Next Steps

We've now thoroughly covered creating, placing, and using Error Boundaries with both class and functional components, including leveraging the react-error-boundary library. The next crucial step in a production application is ensuring these errors are not just caught but also reported. The next article, "Reporting Errors to a Service (Part 1)", will focus on how to integrate external error reporting services like Sentry into your Error Boundary setup.

Keep your UIs stable and your users happy!


glossary

  • useErrorHandler: A hook from react-error-boundary that returns a function; calling this function with an error will propagate it to the nearest Error Boundary.
  • Programmatic Error Handling: Triggering error handling logic explicitly through code (e.g., calling useErrorHandler(error)) rather than relying solely on errors thrown during rendering.
  • Higher-Order Component (HOC): A function that takes a component and returns a new component, often used to share component logic or enhance components. withErrorBoundary is an example.
  • Asynchronous Operation: Code that doesn't execute immediately or sequentially, such as data fetching, setTimeout, or Promise resolutions.

Further Reading