Infinite loop in useEffect with constant dependency array using react-error-boundary

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.

Leave a Reply