How does React Hooks useCallback "freezes" the closure?
Asked Answered
T

3

14

I'd like to know how does React "freezes" the closure while using the useCallback hook (and with others as well), and then only updates variables used inside the hook when you pass them into the inputs parameter.

I understand that the "freeze" may not be very clear, so I created a REPL.it that shows what I mean: https://repl.it/repls/RudeMintcreamShoutcast. Once you open the code, open your web browser console and start clicking on the count button.

How come the value outside compared to the one inside, for the same variable, is different, if they're under the same closure and referencing the same thing? I'm not familiar with React codebase and so I suppose I'm missing an under the hood implementation detail here, but I tried to think how that could work for several minutes but couldn't come up with a good understanding on how React is achieving that.

Tripody answered 7/2, 2019 at 15:57 Comment(0)
F
22

The first time the component is rendered, the useCallback hook will take the function that is passed as its argument and stores it behind the scenes. When you call the callback, it will call your function. So far, so good.

The second time that the component is rendered, the useCallback hook will check the dependencies you passed in. If they have not changed, the function you pass in is totally ignored! When you call the callback, it will call the function you passed in on the first render, which still references the same values from that point in time. This has nothing to do with the values you passed in as dependencies - it's just normal JavaScript closures!

When the dependencies change, the useCallback hook will take the function you pass in and replace the function it has stored. When you call the callback, it will call the new version of the function.

So in other words, there's no "frozen"/conditionally updated variables - it's just storing a function and then re-using it, nothing more fancy than that :)

EDIT: Here's an example that demonstrates what's going on in pure JavaScript:

// React has some component-local storage that it tracks behind the scenes.
// useState and useCallback both hook into this.
//
// Imagine there's a 'storage' variable for every instance of your
// component.
const storage = {};

function useState(init) {
  if (storage.data === undefined) {
    storage.data = init;
  }
  
  return [storage.data, (value) => storage.data = value];
}

function useCallback(fn) {
  // The real version would check dependencies here, but since our callback
  // should only update on the first render, this will suffice.
  if (storage.callback === undefined) {
    storage.callback = fn;
  }

  return storage.callback;
}

function MyComponent() {
  const [data, setData] = useState(0);
  const callback = useCallback(() => data);

  // Rather than outputting DOM, we'll just log.
  console.log("data:", data);
  console.log("callback:", callback());

  return {
    increase: () => setData(data + 1)
  }
}

let instance = MyComponent(); // Let's 'render' our component...

instance.increase(); // This would trigger a re-render, so we call our component again...
instance = MyComponent();

instance.increase(); // and again...
instance = MyComponent();
Freberg answered 7/2, 2019 at 16:18 Comment(5)
So, my concern is exactly "which still references the same values from that point in time". How come the useCallback is different from this? gist.github.com/rdsedmundo/f393f1201336c26625022215b77af711Tripody
@EdmundoRodrigues: Ah, I see what you mean - in your example, a references the same variable from the same scope both times you call your callback. In your original code, since the whole function has been rerun upon render, count is referring a different variable outside and inside the callback. Hang on, I'll try to cook up a more representative example.Freberg
@EdmundoRodrigues: I've added an example which demonstrates what's happening without any React-specific code - hopefully it's helpful!Freberg
ahhh bummer. My whole confusion was because I was thinking that React was actually creating "instances" of the components. But there's no such thing on function components it seems. Thanks!Tripody
@EdmundoRodrigues: There is an 'instance' of the component, but it's just managed behind the scenes when you're using functional components/hooks, instead of being exposed to you as this. You're effectively just writing the render method on its own and telling React to handle the rest for you :)Freberg
M
1

I came here with a similar, rather vague uncertainty about the way useCallback works, its interaction with closures, and the way they are "frozen" by it. I'd like to expand a bit on the accepted answer by proposing to look at the following setup, which shows the working of useCallback (the important aspect is to ignore the linter's warning, for pedagogical reasons):

function App() {

  const [a, setA] = useState(0)

  const incrementWithUseCallback = useCallback(() => {
    // As it closes on the first time `App` is called, the closure is "frozen" in an environment where a=0, forever
    console.log(a)
    setA(a + 1)
  }, []) // but.. the linter should complain about this, saying that `a` should be included!

  const incrementWithoutUseCallback = () => {
    // This will see every value of a, as a new closure is created at every render (i.e. every time `App` is called)
    console.log(a)
    setA(a + 1)
  }

  return (
    <div>
      <button onClick={incrementWithUseCallback}>Increment with useCallback</button>
      <button onClick={incrementWithoutUseCallback}>Increment without useCallback</button>
    </div>
  )
}

So we clearly see that useCallback effectively "freezes" its closure at a certain moment in time, which is a concept that must be understood clearly, in order to avoid confusing problems, which are sometimes also referred as "stale closures". This article probably does a better job of explaining it than me: https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures

Matronna answered 19/11, 2022 at 22:42 Comment(0)
A
0

Here's a slightly another view on example code provided by Joe Clay, which emphasizes closure context in which callback is called.

//internal store for states and callbacks
let Store = { data: "+", callback: null };

function functionalComponent(uniqClosureName) {
  const data = Store.data;//save value from store to closure variable
  const callback = Store.callback = Store.callback || (() => {
       console.log('Callback executed in ' + uniqClosureName + ' context');
       return data;
    });
  console.log("data:", data, "callback():", callback());
  return {
    increase: () => Store.data = Store.data + "+"
  }
}
let instance = functionalComponent('First render');
instance.increase();
instance = functionalComponent('Second render');
instance.increase();
instance = functionalComponent('Third render');

As you see, callback without dependencies will be always executed in the closure where it was memorized by useCallback, thus 'freezing' closure.

It happens because when function for callback is created, it is created only once, during first 'render'. Later this function is re-used, and use value of data which was recorded from Store.data during first call.

In the next example you can see the closure 'freezing' logic "in essence".

let globalX = 1;
const f = (() => {
   let localX = globalX; return () => console.log(localX); }
)();
globalX = 2;//does not affect localX, it is already saved in the closure
f();//prints 1
Alli answered 8/12, 2022 at 14:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.