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 is a senior UI engineer at Microsoft.