In my example below, why is there an infinite loop of errors in the devtools console? It seems like resetErrorBoundary()
is causing useEffect
to trigger again and vice versa leading to an infinite loop, but I don’t understand why useEffect
would keep running even with a constant value in its dependency array.
This answer solves the problem by explicitly checking for changes to the dependency array value with an if-statement, but shouldn’t useEffect
do that automatically? I would expect an if-statement like that to be redundant.
https://codesandbox.io/p/github/adamerose/error-boundary-example/main?file=%2Fsrc%2FApp.jsx
import { useEffect } from "react";
import { ErrorBoundary } from "react-error-boundary";
function ThisComponentWillError() {
throw Error("SomeError");
}
function App() {
return (
<main>
<StandardErrorBoundary>
<ThisComponentWillError />
</StandardErrorBoundary>
</main>
);
}
function ErrorFallback({ error, resetErrorBoundary }) {
useEffect(() => {
resetErrorBoundary();
}, ["CONSTANT"]);
return (
<div>
<p>Something went wrong:</p>
<pre>{error.toString()}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function StandardErrorBoundary({ children }) {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>
);
}
export default App;
Note – This is just a minimal example. My actual project has location.pathname
in the dependency because I want to reset errors on URL navigation, but I realized no matter what I had in the dependency array it would infinitely loop.
>Solution :
One way to debug something like this is to inspect the source code of the library being. used. Thankfully react-error-boundary
is just one component, it was relatively easier to inspect.
You are assuming that the ErrorFallback
component is re-rendered when resetErrorBoundary
is called. Instead it is completely remounted. Once remounted all effects will run again, cause it is like a new first invocation of the function.
Here is the source code. I have commented the irrelevant part:
const initialState: ErrorBoundaryState = {error: null}
class ErrorBoundary extends React.Component<
React.PropsWithRef<React.PropsWithChildren<ErrorBoundaryProps>>,
ErrorBoundaryState
> {
static getDerivedStateFromError(error: Error) {
return {error}
}
state = initialState
resetErrorBoundary = (...args: Array<unknown>) => {
this.props.onReset?.(...args)
this.reset()
}
reset() {
this.setState(initialState)
}
....
....
render() {
const {error} = this.state
const {fallbackRender, FallbackComponent, fallback} = this.props
if (error !== null) {
const props = {
error,
resetErrorBoundary: this.resetErrorBoundary,
}
if (React.isValidElement(fallback)) {
return fallback
} else if (typeof fallbackRender === 'function') {
return fallbackRender(props)
} else if (FallbackComponent) {
return <FallbackComponent {...props} />
} else {
throw new Error(
'react-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop',
)
}
}
return this.props.children
}
}
So once resetErrorBoundary
is called. The state is re-initialized and becomes {error:null}
. Now in that case the children of the error boundary wrapped code will be rendered instead of the fallback. In the above case, the child tree again throws an error and hence state becomes something other than {error:null}
. The render method of ErrorBoundary
is called again and this time the Fallback component is rendered because this time
this.state.error` is not null. Hence completing the loop and this goes on.
PS: I was able to find out that the component was getting remounted by running a useEffect
with empty dependency.