DEV Community

Cover image for Hooks, Dependencies and Stale Closures
Dominik D
Dominik D

Posted on • Edited on • Originally published at tkdodo.eu

Hooks, Dependencies and Stale Closures

Working with closures is something that can become quite tricky, especially when dealing with hooks that have dependencies in React (think useEffect, useMemo, useCallback). A lot of bugs and frustration are tied pretty closely to that concept - even though it is nothing that React itself introduced. It's rather a language concept that hooks rely upon.

I love this question from Mark Erikson:

To me, it has gotten subjectively better. Working with this in class components was a pain, and errors did mostly show up at runtime. However, the behaviour you get due to stale closures are more subtle, and come up in more edge cases. The big advantage though is that they can be statically analyzed - and that's exactly what the react-hooks/exhaustive-deps eslint rule does.

In this article, I'll try to break down what stale closures are, what they have to do with React and hooks, and why the lint rule is so important that I think you should set it to error. To get there, we must first understand what (stale) closures are:

What are closures

I find the concept of closures somewhat hard to explain. Let's take a look at the definition on MDN:

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

I would best rephrase this as: JavaScript functions can "see", and interact with, things that were defined outside of them. You might not know it, but you are probably using this concept very often, for example, when using props of your React component inside a callback:

function Counter({ count }) {
  const logCount = () => {
    // 💡 accessing count from the outer scope
    console.log('count', count)
  }

  return <button onClick={logCount}>Show me the count</button>
}
Enter fullscreen mode Exit fullscreen mode

logCount can access everything we define in the Counter component, for example, the count prop. You can easily check that you are relying on closures by moving the function to the outside of its parent. If it doesn't work anymore, it's because you don't have access to something you were "closing over" anymore:

// ❌ 'count' is not defined. (no-undef)
const logCount = () => {
  console.log('count', count)
}
function Counter({ count }) {
  return <button onClick={logCount}>Show me the count</button>
}
Enter fullscreen mode Exit fullscreen mode

The nice part about closures in React is that it will "just work" if your component re-renders with a new prop. Have a look at this examples (note: examples are interactive on my blog: https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures)

function App() {
  const [count, increment] = React.useReducer((prev) => prev + 1, 1)

  const logCount = () => {
    console.log(count)
  }

  return (
    <div>
      <div>count is {count}</div>
      <button onClick={increment}>increment</button>
      <button onClick={logCount}>log</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

You can click both buttons a bunch of times, and you should see that the log function, which closures over count, will always have access to the "latest" count. Or so it seems.

But why is that, and is that always true?

Taking a picture

The last sentence of the MDN definition is the most important one, so let's have another look (emphasis mine):

In JavaScript, closures are created every time a function is created, at function creation time.

Let me try to explain this with an analogy that has made closures "click" for me:

Suppose every time you are creating a function, you are taking a picture of it. That picture contains everything from the moment when the picture was created. In the foreground, you have the most important things (what the function is doing, the code it is executing etc.). In the background of the picture, you have everything that lives outside your function, but that you are also using inside. It's as if the count variable has photo-bombed our picture - it is also in it.

The thing about the picture is - it cannot change. Once we have taken it, its content is sealed (unless we use photoshop).

Calling a function is just looking at the picture and doing what's on it. We will then see everything from the time when it was created.

Every time the function is created, we throw away the old picture and take a new one. When React re-renders a component tree, it just re-runs everything top down. Here, this works to our advantage: Our logCount function gets re-created because the App component re-renders when the count state is updated.

Because of that, we take a new picture (= re-create the logCount function), that contains the "latest" count variable. So when we click our button, we know the correct count.

Memoization

For 98% of the code we write, this behaviour is great, and as I said, it just works. We don't even have to think about closures. That is, until we introduce memoization.

The remainder of the time, re-creating a function every render just doesn't cut it. Maybe we need to pass it to a memoized child component that is expensive to re-render, so we have memoized it.

For these cases, React offers ways to not create functions (or values) every time, in the form of useCallback and useMemo.

By allowing us to pass a dependency array to those hooks, we can let React know when it should re-create those things, and when it is safe to give us an old version of it.

It comes with the aforementioned eslint rule that tries to point us in the right direction, and tells us which dependencies we should include. Because the rule is set to warn per default, it seems like an innocent thing to ignore. But it is not.

Ignoring the linter

Oftentimes, I see people ignore the rule with arguments like: "I only want to run this effect when the component mounts", or: "I only want to create the function once".

Whenever you do that, no new picture is taken. React will just give you the old one to look at. And as we now know, that means we'll have the old photo-bombed variables at our disposal, as opposed to "the latest ones". And that is commonly referred to as a "stale closure". Because the things you are seeing are not up-to-date, but stale.

We can see how ignoring the linter in our example will lead to non working code:

function App() {
  const [count, increment] = React.useReducer((prev) => prev + 1, 1)

  // 🚨 the linter says we should include count
  // as a dependency, but we don't
  const logCount = React.useCallback(() => {
    log(count)
  }, [])

  return (
    <div>
      <div>count is {count}</div>
      <button onClick={increment}>increment</button>
      <button onClick={logCount}>log</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We instruct React to only create our logCount function once, "on mount". It doesn't have any dependencies (an empty dependency array), so it will always "see" the count from the first render cycle, which is 1. Every time we click the button, it will log 1. This is certainly not what we had in mind.


Obviously, this was a very basic example. We can just include the count in the dependency Array, like the linter wants to, without problems. If count changes, we get a new picture. If our App re-renders for some other reason and count stays the same, we don't have to create a new function, and React can give us the old one. Nothing is stale here, because the only dependency we have is count, and that hasn't changed. This is pretty sweet.

But what about more complex dependencies? Like an object or a callback function that get provided via props that are not referentially stable?

Another example

Once upon a time, we had a component that wasn't fast. It looked something like this:

function SlowComponent({ value, onChange }) {
  return <RenderSomethingSlow value={value} onChange={onChange} />
}
Enter fullscreen mode Exit fullscreen mode

Our idea was to memoize it by wrapping it in React.memo so that it doesn't get rendered too often. Because onChange is a function that gets passed in by consumers, they would need to memoize the function to not make the component slow again.

We thought: "We actually only want to re-render our component when value changes, so why don't we just omit the onChange prop from the comparison function and side-step that problem?" 🤔

const FastComponent = React.memo(
  SlowComponent,
  (prevProps, nextProps) => prevProps.value === nextProps.value
)
Enter fullscreen mode Exit fullscreen mode

As the React docs suggest, we can "return true if passing nextProps to render would return the same result as passing prevProps to render, otherwise return false".

We only care about value for our render result, so what's wrong with this approach?

The answer lies again in stale closures: If the caller component re-creates onChange for some reason, but value doesn't change, we are not taking a new picture of our SlowComponent, which means it still sees the old onChange function:

function User({ name }) {
  const [count, increment] = React.useReducer((prev) => prev + 1, 1)

  // 🚨 name can become stale
  const logUser = () => {
    console.log(name, count)
  }

  return (
    <div>
      <button onClick={increment}>increment</button>
      <button onClick={logUser}>log</button>
      <FastComponent value={count} onChange={logUser} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The logUser function closures over name and count, but FastComponent knows nothing of the name prop. It will only be re-created when value changes, so if onChange is called, it sees the name from the last time count has changed - which might or might not be stale.

This is a very tricky situation to be in, because your application can run perfectly fine for weeks or even months before you get a bug report that is likely very tricky to reproduce.

Don't lie

The best thing you can do is: Don't get yourself in this situation by lying about the dependencies. Functions cannot be easily excluded from dependency arrays, so take the linter seriously, and make that rule an error in your codebase!

Spoiler: There are ways to have your cake and eat it, too, but I will leave that for the next article. 😄


That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below. ⬇️

Top comments (0)