Problem
Basically, you have two things working against you. The first is that the CRA has an ErrorOverlay which captures errors first and the second is that since event handlers are asynchronous, they don't trigger the componentDidCatch/getDerivedStateFromError lifecycles: Issue #11409.
The work-around is to capture unhandledrejection events on the window.
Solution

Code
ErrorBoundary.js
import * as React from "react";
const ErrorBoundary = ({ children }) => {
const [error, setError] = React.useState("");
const promiseRejectionHandler = React.useCallback((event) => {
setError(event.reason);
}, []);
const resetError = React.useCallback(() => {
setError("");
}, []);
React.useEffect(() => {
window.addEventListener("unhandledrejection", promiseRejectionHandler);
return () => {
window.removeEventListener("unhandledrejection", promiseRejectionHandler);
};
/* eslint-disable react-hooks/exhaustive-deps */
}, []);
return error ? (
<React.Fragment>
<h1 style={{ color: "red" }}>{error.toString()}</h1>
<button type="button" onClick={resetError}>
Reset
</button>
</React.Fragment>
) : (
children
);
};
export default ErrorBoundary;
Hello.js
import React, { useEffect } from "react";
export const Hello = () => {
const loader = async () => {
return Promise.reject("API Error");
};
useEffect(() => {
const load = async () => {
try {
await loader();
} catch (err) {
throw err;
}
};
load();
}, []);
return <h1>Hello World!</h1>;
};
index.js
import React from "react";
import { render } from "react-dom";
import { Hello } from "./Hello";
import ErrorBoundary from "./ErrorBoundary";
const App = () => (
<ErrorBoundary>
<Hello />
</ErrorBoundary>
);
render(<App />, document.getElementById("root"));
Other thoughts
A cleaner approach would be to just display a pop-up/notification about the error instead of overriding the entire UI. A larger and more complex UI means an unnecessarily large UI repaint:
