JavaScript and React course Β· Module 16: Error Handling and Suspense

Advanced Error Handling Patterns

6 min read
In this lesson5

Your error boundaries catch failures and show a fallback, but think about it: how will you find out that at three in the morning a thousand people saw the message "Something went wrong"? Users won't send you their console, and you're not sitting next to every browser. In this lesson, we'll explore advanced error handling techniques you can apply in complex React applications. These patterns are like advanced safety systems on a space station - multi-layered, intelligent, and flexible.

Error Monitoring and Logging

In production, you need to know about errors immediately. Integrate your Error Boundary with a monitoring system. The class below does three things: it generates a failure ID, sends a report to the service (the logErrorToService function is your client, for example Sentry) and shows the user a code they can give to support:

1class MonitoredErrorBoundary extends React.Component {
2  constructor(props) {
3    super(props);
4    this.state = { hasError: false, error: null, errorId: null };
5  }
6
7  static getDerivedStateFromError(error) {
8    return { hasError: true, error };
9  }
10
11  componentDidCatch(error, errorInfo) {
12    // Generate a unique error ID
13    const errorId = Date.now().toString(36);
14    this.setState({ errorId });
15
16    // Send to the monitoring service
17    logErrorToService({
18      errorId,
19      message: error.message,
20      stack: error.stack,
21      componentStack: errorInfo.componentStack,
22      timestamp: new Date().toISOString(),
23      url: window.location.href,
24      userAgent: navigator.userAgent
25    });
26  }
27
28  render() {
29    if (this.state.hasError) {
30      return (
31        <div className="error-report">
32          <h3>An error occurred</h3>
33          <p>Error code: {this.state.errorId}</p>
34          <p>Our team has been notified.</p>
35          <button onClick={() => this.setState({ hasError: false })}>
36            Try again
37          </button>
38        </div>
39      );
40    }
41    return this.props.children;
42  }
43}

We send the report in componentDidCatch, not in getDerivedStateFromError, because the latter runs during the render phase and must be pure. Date.now().toString(36) is just a simple time-based identifier, and two failures in the same millisecond would get the same code. In production use crypto.randomUUID() or the identifier returned by your monitoring service.

Context-aware Error Handling

Different parts of the application may require different error handling. A life-support failure is an alarm, a weather widget failure is a log entry. React context (createContext and useContext) lets you pass this policy down to a whole subtree without passing props:

1import { createContext, useContext } from 'react';
2
3const ErrorContext = createContext({
4  onError: (error) => console.error(error),
5  severity: 'normal'
6});
7
8function useErrorHandler() {
9  const { onError, severity } = useContext(ErrorContext);
10
11  return (error) => {
12    if (severity === 'critical') {
13      // For critical sections - send an alert
14      sendAlert(error);
15    }
16    onError(error);
17  };
18}
19
20// Usage
21function CriticalSection({ children }) {
22  return (
23    <ErrorContext.Provider value={{
24      onError: (err) => sendToMonitoring(err),
25      severity: 'critical'
26    }}>
27      <ErrorBoundary>
28        {children}
29      </ErrorBoundary>
30    </ErrorContext.Provider>
31  );
32}

Every component inside CriticalSection can call useErrorHandler() and get a function that knows by itself whether to raise the alarm. In React 19 you can also write the shorter <ErrorContext value={...}>, the .Provider form still works.

Error Recovery Strategies

Showing the error is only the beginning. Here are two recovery strategies.

Strategy 1: Automatic Component Refresh

This strategy uses ErrorBoundary from the react-error-boundary library and a trick with the key prop. When key changes, React destroys the old component and creates a new one, with a clean state:

1function AutoRecoveryBoundary({ children, maxRetries = 3 }) {
2  const [retryCount, setRetryCount] = useState(0);
3  const [key, setKey] = useState(0);
4
5  const handleReset = () => {
6    if (retryCount < maxRetries) {
7      setRetryCount(prev => prev + 1);
8      setKey(prev => prev + 1); // Forces a remount
9    }
10  };
11
12  return (
13    <ErrorBoundary
14      key={key}
15      FallbackComponent={({ error }) => (
16        <div>
17          <p>Error: {error.message}</p>
18          {retryCount < maxRetries ? (
19            <button onClick={handleReset}>
20              Try again ({maxRetries - retryCount} attempts remaining)
21            </button>
22          ) : (
23            <p>Failed to fix the error. Contact support.</p>
24          )}
25        </div>
26      )}
27    >
28      {children}
29    </ErrorBoundary>
30  );
31}

The retryCount counter lives outside the boundary, so the remount doesn't reset it, and that's why the retry limit works. Everything inside the boundary starts from zero.

Strategy 2: Fallback to a Simpler Version

Instead of a message, you can show a poorer but working version of the same information. An interactive chart depends on a heavy library, a plain table doesn't:

1function ChartWithFallback({ data }) {
2  return (
3    <ErrorBoundary
4      FallbackComponent={() => (
5        // Instead of an interactive chart, show a simple table
6        <table>
7          <thead><tr><th>Name</th><th>Value</th></tr></thead>
8          <tbody>
9            {data.map(item => (
10              <tr key={item.id}>
11                <td>{item.name}</td>
12                <td>{item.value}</td>
13              </tr>
14            ))}
15          </tbody>
16        </table>
17      )}
18    >
19      <InteractiveChart data={data} />
20    </ErrorBoundary>
21  );
22}

The data is the same, only the way it's presented changes. The crew still sees the readings, just without animation.

Global Error Handling

Catch unhandled errors at the global level. The error event on window catches exceptions nobody caught, for example from handlers and timers, and unhandledrejection catches rejected promises without a .catch():

1// In the main application component (e.g. App)
2useEffect(() => {
3  const handleUnhandledError = (event) => {
4    console.error('Unhandled error:', event.error);
5    // Send to monitoring
6  };
7
8  const handleUnhandledRejection = (event) => {
9    console.error('Unhandled promise rejection:', event.reason);
10    // Send to monitoring
11  };
12
13  window.addEventListener('error', handleUnhandledError);
14  window.addEventListener('unhandledrejection', handleUnhandledRejection);
15
16  return () => {
17    window.removeEventListener('error', handleUnhandledError);
18    window.removeEventListener('unhandledrejection', handleUnhandledRejection);
19  };
20}, []);

The cleanup function removes both listeners on unmount, so they don't pile up. Keep in mind that, according to react.dev, in development mode errors caught by componentDidCatch also reach window, while in production they don't, so in dev you'll see some errors twice.

React 19 added a second layer: the onCaughtError and onUncaughtError options of createRoot. The first receives errors caught by any boundary, the second those that no boundary caught:

1import { createRoot } from 'react-dom/client';
2
3const root = createRoot(document.getElementById('root'), {
4  onCaughtError: (error, errorInfo) => {
5    // Error caught by an Error Boundary
6    reportToMonitoring('caught', error, errorInfo.componentStack);
7  },
8  onUncaughtError: (error, errorInfo) => {
9    // Error that no boundary caught
10    reportToMonitoring('uncaught', error, errorInfo.componentStack);
11  }
12});
13root.render(<App />);

Both callbacks receive componentStack, so you get one central place to report all rendering errors. By default React simply logs them to the console.

Pattern Summary

Let's gather all the layers of shielding in one table:

PatternWhen to Use
Error BoundaryComponent rendering errors
try/catchEvent handlers and synchronous code
useErrorBoundaryAsync errors in Error Boundary
RetryUnstable network connections
Fallback contentOptional data
Global handlerUnexpected errors

My advice: in a new React 19 project, start with onCaughtError and onUncaughtError in createRoot, because it's the cheapest way to make sure no rendering error goes unnoticed. In the main project of this location you'll combine all these patterns into one resilient mission dashboard.

Remember: a good station doesn't just survive a failure, it also reports it to mission control right away.

Code for this lesson: App.jsx
1import React, { useState, useEffect, createContext, useContext } from 'react';
2import './styles.css';
3
4// Error Logging Context
5const ErrorLogContext = createContext({ logs: [], addLog: () => {} });
6
7function useErrorLog() {
8  return useContext(ErrorLogContext);
9}
10
11// Monitored Error Boundary
12class MonitoredBoundary extends React.Component {
13  constructor(props) {
14    super(props);
15    this.state = { hasError: false, error: null, retries: 0 };
16  }
17  static getDerivedStateFromError(error) {
18    return { hasError: true, error };
19  }
20  componentDidCatch(error, info) {
21    const log = {
22      id: Date.now(),
23      section: this.props.section || 'unknown',
24      message: error.message,
25      time: new Date().toLocaleTimeString(),
26    };
27    if (this.props.onLogError) {
28      this.props.onLogError(log);
29    }
30  }
31  handleRetry = () => {
32    if (this.state.retries < (this.props.maxRetries || 3)) {
33      this.setState(s => ({
34        hasError: false,
35        error: null,
36        retries: s.retries + 1,
37      }));
38    }
39  };
40  render() {
41    if (this.state.hasError) {
42      const remaining = (this.props.maxRetries || 3) - this.state.retries;
43      return (
44        <div className="monitored-error">
45          <h4>{this.props.section} - Failure</h4>
46          <p>{this.state.error.message}</p>
47          {remaining > 0 ? (
48            <button onClick={this.handleRetry} className="retry-btn">
49              Retry ({remaining} attempts)
50            </button>
51          ) : (
52            <p className="no-retry">No attempts left - report the error</p>
53          )}
54        </div>
55      );
56    }
57    return this.props.children;
58  }
59}
60
61function UnstableWidget({ name, fail = 0.5 }) {
62  if (Math.random() < fail) {
63    throw new Error(name + ': system failure');
64  }
65  return (
66    <div className="widget-ok">
67      <span className="dot" /> {name}: Online
68    </div>
69  );
70}
71
72// Error Log Panel
73function ErrorLogPanel({ logs }) {
74  if (logs.length === 0) return (
75    <div className="log-panel">
76      <h3>Error journal</h3>
77      <p className="empty">No errors recorded</p>
78    </div>
79  );
80  return (
81    <div className="log-panel">
82      <h3>Error journal ({logs.length})</h3>
83      {logs.map(log => (
84        <div key={log.id} className="log-entry">
85          <span className="log-time">{log.time}</span>
86          <span className="log-section">[{log.section}]</span>
87          <span className="log-msg">{log.message}</span>
88        </div>
89      ))}
90    </div>
91  );
92}
93
94export default function App() {
95  const [key, setKey] = useState(0);
96  const [logs, setLogs] = useState([]);
97
98  const addLog = (log) => {
99    setLogs(prev => [log, ...prev].slice(0, 10));
100  };
101
102  return (
103    <div className="app">
104      <h1>Error Monitoring</h1>
105      <button onClick={() => setKey(k => k + 1)} className="reset-btn">
106        Restart systems
107      </button>
108
109      <div className="grid" key={key}>
110        <MonitoredBoundary section="Engines" maxRetries={3} onLogError={addLog}>
111          <UnstableWidget name="Engines" fail={0.5} />
112        </MonitoredBoundary>
113
114        <MonitoredBoundary section="Navigation" maxRetries={3} onLogError={addLog}>
115          <UnstableWidget name="Navigation" fail={0.5} />
116        </MonitoredBoundary>
117
118        <MonitoredBoundary section="Communications" maxRetries={3} onLogError={addLog}>
119          <UnstableWidget name="Communications" fail={0.5} />
120        </MonitoredBoundary>
121
122        <MonitoredBoundary section="Sensors" maxRetries={3} onLogError={addLog}>
123          <UnstableWidget name="Sensors" fail={0.5} />
124        </MonitoredBoundary>
125      </div>
126
127      <ErrorLogPanel logs={logs} />
128    </div>
129  );
130}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which Error Boundary method is best for logging errors to an external service?

  2. 2. What does the 'fallback to a simpler version of a component' strategy involve?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Order error recovery strategies from simplest to most complex:

  • Code editor

    Implement RetryErrorBoundary with maxRetries

  • Horizontal ordering

    Arrange the syntax for adding a global error handler:

  • Vertical ordering

    Order the steps for building a resilient application:

  • Code editor

    Implement tabs with lazy loading

  • Vertical ordering

    Order page elements by loading priority (from highest):

  • Horizontal ordering

    Arrange the ErrorBoundary syntax with resetKeys:

  • Code editor

    Build a mini resilient dashboard

  • Vertical ordering

    Order the resilience layers from outermost to innermost:

Useful articles