TypeOfNaN

Fix the "Maximum Update Depth Exceeded" Error in React

Nick Scialli
April 13, 2021

React is an excellent framework, but it can have some tricky “gotchas.” One of which is when you accidentally cause an infinite render loop, often resulting in the cryptic “maximum update depth exceeded” error.

Infinite Render Loop

You likely have gotten yourself into an infinite render loop. Here I’ll discuss the most frequent causes and how to fix them.

Cause 1: Calling a Function Rather Than Passing a Reference to It

You can get yourself into an infinite render loop by accidentally calling a state setter function rather than passing a reference to it. Consider the following code:

function App() {
  const [terms, setTerms] = useState(false);

  const acceptTerms = () => {
    setTerms(true);
  };

  return (
    <>
      <label>
        <input type="checkbox" onChange={acceptTerms()} /> Accept the Terms
      </label>
    </>
  );
}

We’re immediately calling acceptTerms rather than passing a reference to a function to the change handler. This means that, when the component renders, we immediately call acceptTerms, causing the component to re-render, and then immmediately call acceptTerms, and so on. To get ourselves out of this bind, we need to pass a reference to acceptTerms rather than immediately calling it:

function App() {
  const [terms, setTerms] = useState(false);

  const acceptTerms = () => {
    setTerms(true);
  };

  return (
    <>
      <label>
        <input type="checkbox" onChange={acceptTerms} /> Accept the Terms
      </label>
    </>
  );
}

Cause 2: An Effect that Updates a Variable in Its Own Dependency Array

A nice feature of React is that it’s reactive. We can use the useEffect hook to take an action based on a variable being updated. However, this can backfire: if the useEffect hook updates a variable that triggers the effect to re-run, then it’s going to keep updating and re-running, causing an infinite loop. Let’s consider the following example:

function App() {
  const [views, setViews] = useState(0);

  useEffect(() => {
    setViews(views + 1);
  }, [views]);

  return <>Some content</>;
}

In this case, the useEffect hook will run when views is updated. But when the hook runs, views then gets updated and, in turn, causes the effect to run yet again. This becomes an infinite loop.

One way to prevent this from happening is to use a callback function in your state setter:

setViews((v) => v + 1);

This will allow you to safely remove the views variable from the dependency array.

function App() {
  const [views, setViews] = useState(0);

  useEffect(() => {
    setViews((v) => v + 1);
  }, []);

  return <>Some content</>;
}

Now the effect will only run on initial render and won’t be run when views is updated.

If you can’t clean up the dependency array

There might be some reason you can’t clean up the dependency array. It’s not optimal, but you could add an additional variable to the mix that limits whether the effect is re-run. For example, you could create an isInitialRender stateful variable.

function App() {
  const [views, setViews] = useState(0);
  const [isInitialRender, setIsInitialRender] = useState(true);

  useEffect(() => {
    if (isInitialRender) {
      setIsInitialRender(false);
      setViews(v + 1);
    }
  }, [views, isInitialRender]);

  return <>Some content</>;
}

The setViews function will now only be called when isInitialRender is true, meaning when the effect is re-run, the views variable will not be incremented and we won’t re-trigger the effect.

Cause 3: Your Effect Depends On a Function That’s Declared Inside the Component

The useEffect hook uses referential equality to determine if any variables changed in its dependency array. This means that, even if an object appears identical to another object, the effect could be re-triggered because the objects are actually different in memory.

A good example of this is when an effect depends on a function that’s declared within the component itself. For example:

function App() {
  const [views, setViews] = useState(0);

  const incrementViews = () => {
    setViews((v) => v + 1);
  };

  useEffect(() => {
    incrementViews();
  }, [incrementViews]);

  return <>Some content</>;
}

Even though incrementViews appears like it will be the same function on every render, there’s actually a new function created in memory on each render.

React has a couple solutions for this issue.

Moving the Function Inside the Effect

One quick solution is to move the function inside the useEffect hook.

function App() {
  const [views, setViews] = useState(0);

  useEffect(() => {
    const incrementViews = () => {
      setViews((v) => v + 1);
    };
    incrementViews();
  }, []);

  return <>Some content</>;
}

We can see that now incrementViews is scoped inside the useEffect hook and a new function will only be created the one time the effect is run (on component mount). Since the function is inside the effect, we don’t even need to include it in the dependency array!

The useCallback Hook

The useCallback hook works a lot like the useEffect hook where the first argument is a function and the second argument is a dependency array. The difference is the useCallback hook will not create a new function unless one of the variables in its dependency array changes.

We can see the useCallback hook in action here:

function App() {
  const [views, setViews] = useState(0);

  const incrementViews = useCallback(() => {
    setViews((v) => v + 1);
  }, []);

  useEffect(() => {
    incrementViews();
  }, [incrementViews]);

  return <>Some content</>;
}

Now, incrementViews will always refer to the same object in memory.

Conclusion

React is awesome because it reacts to changes in state really well. Sometimes too well if you’re not careful! Hopefully these tips have solved any issues you’re currently hitting.

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli