Photo by Samuel Zeller on Unsplash

I recently ran into a point of confusion on how useEffect behaved. I posted to twitter for help.

The inimitable Dan Abramov jumped on to help out. His commitment to helping people learn the framework he works on is inspirational frankly. His response:

Dan’s statement is something I should have known, as it’s stated clearly in the docs:

If you use this optimization, make sure the array includes any values from the outer scope that change over time and that are used by the effect. Otherwise, your code will reference stale values from previous renders. We’ll also discuss other optimization options in the Hooks API reference.

Dan posted a revised codesandbox that worked correctly here.

A close reading

I think his response has everything one needs to know. But I want to talk through his point, and his solution, to see if I can see where my mental model was going wrong. Here was the problematic code I’d written:

const [clicks, setClicks] = useState(0);

const [sideEffects, setSideEffects] = useState(0);

const [cleanups, setCleanups] = useState(0);

useEffect( () => {

setSideEffects(sideEffects + 1);

return () => setCleanups(cleanups + 1);

}, [clicks] );

The problem was that useEffect will only run after the initial rendering, and then after any values in the second argument array update. A naive reading of Dan’s suggestion (i.e., my initial reading) was that I should add cleanups to my useEffect second argument. But this did not fix the problem.

I was worried it could somehow cause an infinite loop, but that wasn’t the case. It just didn’t change anything. That makes sense, because setCleanups(cleanups + 1) doesn’t get called whenever useEffect does; it gets called after the button gets clicked. Then when the button does get clicked, clicks will update, and trigger a cleanup, then the "cleanup" function runs, and updates cleanups , which then triggers an extra update. So sideEffects was now doubling the amount of times I’d clicked, and cleanups was still showing half of the sideEffects number.

The problem was in my mental model. What I was right about was this: there is the update phase, then the render phase, then the cleanup function runs, then the useEffect function runs. But I was thinking that if values updated at any time in the process, then the updated values would be available in the next step. So I was assuming that if the cleanup function updated the value of cleanups , then that variable would be updated in the next step, when the useEffect function ran.

But that’s not how hooks work! The function forms a closure over the values only once during the update->render->cleanup->useEffect phase: during update! So I needed to realize that I couldn’t count on any changes made after update to be reflected inside my functions when they got called.

Realizing this, it makes total sense why this wasn’t working. The closure of the cleanup and the closure of the useEffect were always pointing to variables from different updates. The cleanup was always pointing to the variable from the prior update. So they weren’t communicating!

The reason for the problem is a bit in the weeds, but the way to describe it is simple. Dynamic values in useEffect must be assumed to be stale if a second argument array is passed to useEffect, unless the dynamic value in question is included in the second argument array.

A Better Way?

So what was the solution? Dan’s solution was to convert the setState update function calls I was making to passing functions instead of values. So instead of setCleanups(cleanups + 1) , we use setCleanups(c => c + 1) . This is a neat, clean solution, because React passes the latest value to the setCleanup when calling the passed function, so you are assured it is the latest and greatest. No more stale value problem.

Why wasn’t the solution to add cleanups to the second argument array of the useEffects function? That’s how the problem is specified, so why isn’t that the solution? It goes back to what we saw earlier. The useEffect and the "cleanup" functions are always pointing to cleanups from different updates. The "cleanup" is always one behind, so there is not a way for them to communicate with each other. Well, there is a way. Dan’s solution! Pass a function to the useState updater.

As a bonus, there is one more solution. We might consider using useReducer instead of useState if we are finding our use case becoming complex. This hook will use a predefined reducer function for updates, so we can be assured it is always getting the latest version of state, so there won’t even be a question.

Finally, I should add that Dan and the React team are working on adding a linting rule to catch this scenario to the React eslint hooks plugin. So instead of getting Dan helping out in my tweets, I’ll get the React team helping out directly in my console!