Skip to main content

Creating a Reusable Error Boundary Component (Part 2) #139

📖 Introduction

In Creating a Reusable Error Boundary Component (Part 1), we built the basic structure of our ErrorBoundary class component, enabling it to catch errors and display a fallback UI. This second part focuses on enhancing our ErrorBoundary with crucial features: implementing componentDidCatch for robust error logging, and adding functionality to allow users to "try again" by resetting the error state. These additions will make our component more production-ready.


📚 Prerequisites

Before we begin, ensure you have:

  • Completed Part 1 and have the basic ErrorBoundary component.
  • Understanding of static getDerivedStateFromError().
  • Familiarity with React class component lifecycle methods and state management.

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • Implementing componentDidCatch(): Logging error details and component stack traces.
  • Integrating with an Error Reporting Service (Conceptual): Where to send your logs.
  • Adding a "Reset" Functionality: Allowing the error boundary to attempt re-rendering its children.
  • Designing Props for Reset Logic: How to trigger a reset from the fallback UI.
  • Updating the ErrorBoundary and Testing the Reset Feature.

🧠 Section 1: Implementing componentDidCatch() for Error Logging

While static getDerivedStateFromError() is used to update state and trigger a fallback UI, componentDidCatch() is the place for side effects, primarily logging the error information.

Let's add it to our ErrorBoundary.jsx:

// src/components/ErrorBoundary.jsx
import React, { Component } from 'react';

class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}

static getDerivedStateFromError(error) {
console.log('ErrorBoundary: getDerivedStateFromError caught an error:', error);
return { hasError: true, error: error };
}

// --- ADD THIS METHOD ---
componentDidCatch(error, errorInfo) {
// error: The error that was thrown
// errorInfo: An object with a componentStack key
console.error("ErrorBoundary: componentDidCatch caught an error:", error);
console.error("ErrorBoundary: Component stack:", errorInfo.componentStack);

// You can also log the error to an error reporting service here
// For example: myErrorTrackingService.log({ error, errorInfo });

// Store errorInfo in state if you want to display it (e.g., in development)
this.setState({ errorInfo: errorInfo });
}
// --- END OF ADDED METHOD ---

render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div style={{ padding: '20px', border: '1px solid red', margin: '10px' }}>
<h2>Oops! Something went wrong.</h2>
<p>We're sorry, an unexpected error occurred in this section of the application.</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{ whiteSpace: 'pre-wrap', marginTop: '10px' }}>
<summary>{this.state.error.toString()}</summary>
{/* Now errorInfo should be available from componentDidCatch */}
{this.state.errorInfo ? this.state.errorInfo.componentStack : 'Component stack not available yet.'}
</details>
)}
</div>
);
}
return this.props.children;
}
}

export default ErrorBoundary;

Changes and Explanation:

  1. componentDidCatch(error, errorInfo) Added:
    • This method is called by React after getDerivedStateFromError() if an error is caught from a descendant.
    • It receives the error object and an errorInfo object. The errorInfo.componentStack property provides a string trace of the component hierarchy that led to the error, which is invaluable for debugging.
  2. Logging:
    • We console.error both the error and the component stack. In a real application, this is where you would integrate with a third-party error reporting service like Sentry, LogRocket, Bugsnag, etc.
    • Example: myErrorTrackingService.log({ error, errorInfo });
  3. Storing errorInfo:
    • We call this.setState({ errorInfo: errorInfo }) to store the componentStack. This allows us to display it in our development-mode fallback UI, making debugging directly in the browser easier.
    • Note: Calling setState in componentDidCatch is safe because it's in the "commit" phase.

Now, when an error occurs, not only will the fallback UI be shown, but detailed error information including the component stack will be logged to the console (and potentially to your error tracking service). This makes identifying the source of the error much more efficient.


💻 Section 2: Integrating with an Error Reporting Service (Conceptual)

Manually checking browser consoles for errors, especially in a production application used by many users, is impractical. This is where error reporting services shine.

Popular Services:

  • Sentry (sentry.io): Very popular, excellent React integration.
  • LogRocket (logrocket.com): Combines error tracking with session replay.
  • Bugsnag (bugsnag.com): Another robust error monitoring tool.
  • New Relic, Datadog, Azure Application Insights: These broader APM (Application Performance Monitoring) tools often include error tracking features.

How Integration Typically Works:

  1. Sign Up and Install SDK: You sign up for the service and install their JavaScript SDK (e.g., npm install @sentry/react).
  2. Initialize SDK: You initialize the SDK early in your application's lifecycle (e.g., in index.js), providing your project's unique key (DSN for Sentry).
    // index.js (Example with Sentry)
    import * as Sentry from "@sentry/react";
    import { BrowserTracing } from "@sentry/tracing";

    Sentry.init({
    dsn: "YOUR_SENTRY_DSN_HERE",
    integrations: [new BrowserTracing()],
    tracesSampleRate: 1.0, // Capture 100% of transactions for performance monitoring (adjust as needed)
    });
  3. Log in componentDidCatch:
    // ErrorBoundary.jsx
    import * as Sentry from '@sentry/react'; // Or your chosen service SDK

    // ... inside componentDidCatch(error, errorInfo)
    componentDidCatch(error, errorInfo) {
    this.setState({ errorInfo: errorInfo });
    // Send the error to Sentry (or your service)
    Sentry.withScope(scope => {
    scope.setExtras(errorInfo); // Send componentStack as extra data
    Sentry.captureException(error);
    });
    console.error("ErrorBoundary caught:", error, errorInfo); // Still good for local dev
    }
    Most services provide a captureException or similar method. You can often add extra context (like errorInfo, user ID, application version) to the error report.

By integrating such a service, errors caught by your ErrorBoundary components in production will be automatically sent to a centralized dashboard where your team can track, prioritize, and debug them.


🛠️ Section 3: Adding "Reset" Functionality

Sometimes, an error might be transient (e.g., a temporary network blip that affected a lazy-loaded component). In such cases, it would be helpful to allow the user to try rendering the problematic section again. To do this, our ErrorBoundary needs a way to reset its hasError state.

3.1 - Designing the Reset Mechanism

We can add a method to our ErrorBoundary that resets its state. This method can then be passed down to a custom fallback UI, allowing a "Try Again" button to trigger it.

New Prop:

  • onReset: An optional callback function that the ErrorBoundary will call when it attempts to reset. This can be useful for the parent component to clear any related state that might have caused the error.
  • The fallback UI will need access to a function to trigger this reset. We can pass a resetError function as a prop to a fallbackComponent or make it available if the fallback prop is a function.

Let's modify ErrorBoundary.jsx:

// src/components/ErrorBoundary.jsx
import React, { Component } from 'react';

class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}

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

componentDidCatch(error, errorInfo) {
this.setState({ errorInfo: errorInfo });
// Log to error reporting service
console.error("ErrorBoundary caught:", error, errorInfo.componentStack);
// Sentry.withScope... or similar
}

// --- ADD THIS METHOD ---
resetErrorBoundary = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
// Call onReset prop if provided
if (this.props.onReset) {
this.props.onReset();
}
};
// --- END OF ADDED METHOD ---

render() {
if (this.state.hasError) {
// Option 1: Pass reset function to a fallbackComponent prop
if (this.props.fallbackComponent) {
const FallbackComponent = this.props.fallbackComponent;
return <FallbackComponent error={this.state.error} errorInfo={this.state.errorInfo} resetError={this.resetErrorBoundary} />;
}
// Option 2: If fallback is a render prop (function)
if (typeof this.props.fallback === 'function') {
return this.props.fallback({ error: this.state.error, errorInfo: this.state.errorInfo, resetError: this.resetErrorBoundary });
}
// Option 3: If fallback is a React element (less flexible for passing reset)
// We'll provide a default button if no better fallback is given.
if (this.props.fallback) {
// For simplicity, if it's just an element, we can't easily inject resetError.
// Consider using fallbackComponent or render prop for interactive fallbacks.
return this.props.fallback;
}

// Default fallback UI with a reset button
return (
<div style={{ padding: '20px', border: '1px solid red', margin: '10px' }}>
<h2>Oops! Something went wrong.</h2>
<p>We're sorry, an unexpected error occurred.</p>
<button onClick={this.resetErrorBoundary}>Try Again</button>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{ whiteSpace: 'pre-wrap', marginTop: '10px' }}>
<summary>{this.state.error.toString()}</summary>
{this.state.errorInfo && this.state.errorInfo.componentStack}
</details>
)}
</div>
);
}
return this.props.children;
}
}

export default ErrorBoundary;

Changes:

  1. resetErrorBoundary() method:
    • This new method resets the state (hasError, error, errorInfo) back to its initial values.
    • It also calls an optional this.props.onReset() callback, allowing parent components to perform cleanup or state resets if necessary.
  2. Modified render() for Fallbacks:
    • fallbackComponent prop (New): If a fallbackComponent prop (a React component) is provided, it's rendered and passed error, errorInfo, and our resetErrorBoundary function as props. This is a very flexible way to create custom, interactive fallback UIs.
    • fallback as a render prop (New): If fallback is a function, it's called with an object containing error, errorInfo, and resetErrorBoundary. This is another powerful pattern for custom fallbacks.
    • fallback as an element (Existing): If it's just a React element, we render it as before. This pattern doesn't easily allow passing the resetErrorBoundary function.
    • Default Fallback: Our default fallback UI now includes a "Try Again" button that calls this.resetErrorBoundary.

🔬 Section 4: Testing the Reset Functionality

Let's update our App.js to test the reset feature, especially with the fallbackComponent prop.

1. Create a Custom Fallback Component:

// src/components/CustomErrorFallback.jsx
import React from 'react';

const CustomErrorFallback = ({ error, errorInfo, resetError }) => {
return (
<div style={{ backgroundColor: 'lightcoral', color: 'white', padding: '20px', margin: '10px', borderRadius: '5px' }}>
<h3>App Component Error! (Custom Fallback)</h3>
<p>An error occurred: <strong>{error && error.toString()}</strong></p>
<button
onClick={resetError}
style={{ padding: '10px', backgroundColor: 'white', color: 'black', border: 'none', borderRadius: '3px', cursor: 'pointer' }}
>
Try to reload this section
</button>
{process.env.NODE_ENV === 'development' && errorInfo && (
<details style={{ whiteSpace: 'pre-wrap', marginTop: '10px' }}>
<summary>Component Stack (Dev Only)</summary>
{errorInfo.componentStack}
</details>
)}
</div>
);
};

export default CustomErrorFallback;

This component accepts error, errorInfo, and resetError as props and includes a button to call resetError.

2. Update ProblematicComponent.jsx to be resettable: For the "Try Again" to actually work, the ProblematicComponent needs to be able to recover or change its state so it doesn't immediately throw the same error. Let's modify it so the error is only thrown once, or can be reset.

// src/components/ProblematicComponent.jsx
import React, { useState, useEffect } from 'react';

const ProblematicComponent = ({ id }) => { // Added id for uniqueness if needed
const [throwError, setThrowError] = useState(false);
const [attempt, setAttempt] = useState(1);

// This effect will reset the error throwing capability if the component is "retried"
// by the error boundary (i.e. if its key changes or it's unmounted/remounted).
// A more robust reset might come from an onReset prop from the ErrorBoundary's parent.
useEffect(() => {
console.log(`ProblematicComponent ${id} mounted/updated, attempt: ${attempt}`);
setThrowError(false); // Reset error throwing state on new attempt
}, [attempt, id]);


if (throwError && attempt === 1) { // Only throw on the first attempt after clicking
console.error(`ProblematicComponent ${id} is throwing error on attempt ${attempt}`);
throw new Error(`Test error from ProblematicComponent ${id} (Attempt ${attempt})`);
}

const trigger = () => {
console.log(`ProblematicComponent ${id} button clicked, setting throwError=true for attempt ${attempt}`);
setThrowError(true);
}

return (
<div style={{border: '1px dashed green', padding: '10px', margin: '5px'}}>
<p>I am Problematic Component {id}. (Attempt: {attempt})</p>
<button onClick={trigger}>
Click me to throw an error (Attempt {attempt})
</button>
{throwError && attempt > 1 && <p style={{color: 'green'}}>Successfully re-rendered after an error!</p>}
</div>
);
};

export default ProblematicComponent;

Here, we added an attempt state. The component now only throws an error on attempt === 1 after the button is clicked. If the ErrorBoundary resets and re-renders this component (especially if we give it a new key or if the parent managing the onReset callback forces a re-render), attempt might effectively reset or the condition for erroring won't be met. A more explicit way for ErrorBoundary to tell ProblematicComponent to reset its internal error state would be via a callback or key change, which is an advanced pattern. For now, clicking "Try Again" on the boundary will reset the boundary's state. If ProblematicComponent is simply re-rendered as a child, its internal state persists. To make ProblematicComponent truly resettable by the boundary's "Try Again", we might need to pass a key prop to it that changes upon reset.

3. Update App.js:

// src/App.js
import React, { useState } from 'react'; // Added useState
import ErrorBoundary from './components/ErrorBoundary';
import ProblematicComponent from './components/ProblematicComponent';
import CustomErrorFallback from './components/CustomErrorFallback';
import './App.css';

function App() {
// State to help reset ProblematicComponent by changing its key
const [problematicKey1, setProblematicKey1] = useState(1);
const [problematicKey2, setProblematicKey2] = useState(1);

const handleResetBoundary1 = () => {
console.log("Resetting Boundary for Section 2's ProblematicComponent");
setProblematicKey1(prevKey => prevKey + 1);
};

const handleResetBoundary2 = () => {
console.log("Resetting Boundary for Section 3's ProblematicComponent");
setProblematicKey2(prevKey => prevKey + 1);
};


return (
<div className="App">
<header className="App-header"><h1>React Error Boundary Test - Part 2</h1></header>
<main>
{/* ... (Section 1 as before) ... */}
<hr />
<section>
<h2>Section 2: Default Fallback with Reset</h2>
<ErrorBoundary onReset={handleResetBoundary1}>
<p>Default fallback with reset:</p>
<ProblematicComponent id="default" key={`problematic1-${problematicKey1}`} />
</ErrorBoundary>
</section>
<hr />
<section>
<h2>Section 3: `fallbackComponent` with Reset</h2>
<ErrorBoundary fallbackComponent={CustomErrorFallback} onReset={handleResetBoundary2}>
<p>Custom `fallbackComponent` with reset:</p>
<ProblematicComponent id="custom" key={`problematic2-${problematicKey2}`} />
</ErrorBoundary>
</section>
{/* ... (Section 4 as before) ... */}
</main>
</div>
);
}
export default App;

Testing the Reset:

  1. In Section 2 (Default Fallback):
    • Click the "Click me to throw an error" button. The default error fallback appears with a "Try Again" button.
    • Click "Try Again". The ErrorBoundary resets. Because we're changing the key of ProblematicComponent via handleResetBoundary1, React will unmount the old instance and mount a new one. The new instance of ProblematicComponent will have its attempt state reset to 1 (due to remount) and throwError to false, so it renders normally.
    • Clicking its button again will re-throw the error (as attempt will be 1 for this new instance).
  2. In Section 3 (fallbackComponent):
    • Click the "Click me to throw an error" button. The CustomErrorFallback UI appears.
    • Click its "Try to reload this section" button. This calls resetError (which is this.resetErrorBoundary from ErrorBoundary), and also handleResetBoundary2 is called via the onReset prop.
    • Similar to above, the ProblematicComponent gets a new key and remounts, rendering normally.

This demonstrates that our resetErrorBoundary method works and can be triggered from custom fallback UIs, and by changing the key of the child component, we can ensure it gets a fresh start.


💡 Conclusion & Key Takeaways (Part 2)

Our reusable ErrorBoundary component is now significantly more robust. By implementing componentDidCatch, we can effectively log errors and component stack traces, crucial for debugging. The addition of a reset mechanism, coupled with flexible props like fallbackComponent and onReset, provides a way to recover from transient errors and offer users a path forward.

Key Takeaways:

  • componentDidCatch(error, errorInfo) is essential for logging errors and component stack traces to reporting services.
  • A resetErrorBoundary method can clear the error state, allowing children to be re-rendered.
  • Passing the reset function to a fallbackComponent or via a render prop allows custom fallback UIs to trigger a reset.
  • The onReset prop allows parent components to participate in the reset logic (e.g., by changing a child's key to force a remount or clearing related data).

This ErrorBoundary component is now a solid, reusable utility for making your React applications more resilient!


➡️ Next Steps

With a fully functional reusable ErrorBoundary component in hand, the next article, "Where to Place Error Boundaries", will discuss strategies for effectively placing these boundaries throughout your application to maximize their benefit.

Keep building resilient applications!


glossary

  • Error Reporting Service: A third-party service (e.g., Sentry, LogRocket) that collects, tracks, and helps manage errors occurring in production applications.
  • Component Stack Trace: A list of React components in the hierarchy leading up to the point where an error occurred, useful for debugging. Provided by errorInfo.componentStack in componentDidCatch.
  • Render Prop: A technique in React where a component uses a prop that is a function to render something. The function receives arguments and returns a React element. Our fallback prop (if it's a function) acts as a render prop.
  • Transient Error: A temporary error that might resolve itself if the operation is retried (e.g., a brief network interruption).

Further Reading