How can you get burned with useCallback and closures?

image

I work at Ramblr, an AI startup where we build complex video annotation applications in React. I recently came across a complex memory leak that occurs when using JavaScript closures and a hook at the same time

useCallback

in React. Since I grew up using .NET, it took me a long time to figure out what was going on. So I decided to write this post and tell you what this situation taught me.

First, I'll give you a quick refresher on how closures work, but feel free to skip this section if you already have a good understanding of how closures work in JavaScript.

A quick reminder of how closures work

Closures are a fundamental concept in JavaScript. Thanks to closures, a function remembers those variables that were in scope at the time the function was created. Here's a simple example:

function createCounter() {
  const unused = 0; // Эта переменная не используется во внутренней функции
  let count = 0; // Эта переменная используется во внутренней функции
  return function () {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2

In this example, the function

createCounter

returns a new function that has access to the variable

count

. This is possible because at the time the internal function is created, the variable

count

is in the scope of the function

createCounter

.

Closures in JavaScript are implemented using a context object, which contains references to variables that were in scope at the time the function was created. It depends on the JavaScript engine implementation which variables are stored in the context object, and this aspect lends itself to various optimizations. For example, in V8 (the JavaScript engine used in the Chrome browser), unused variables may not be stored in the context object.

Because it is possible to nest closures within other closures, the deepest closures will contain references (called scope chains) to the scopes of any other functions they may need to access. For example:

function first() {
  const firstVar = 1;
  function second() {
    // Это замыкание, заключающее переменную firstVar 
    const secondVar = 2;
    function third() {
      // Это замыкание, заключающее переменные firstVar и secondVar 
      console.log(firstVar, secondVar);
    }
    return third;
  }
  return second();
}

const fn = first(); // Этот код вернёт третью функцию
fn(); // логирует 1, 2

In this example, the function

third()

through a chain of scopes there is access to a variable

firstVar

.

So, as long as the application contains a function reference, none of the variables in the closure's scope are subject to garbage collection. Since we are dealing with a chain of scopes here, even the scopes of external functions will remain in memory.

By the way, read this wonderful article that discusses this topic in detail: Grokking V8 closures for fun (and profit?). Although this article was written in 2012, it is still relevant and provides an excellent overview to understand how closures work in V8.

Closures and React

In React, when working with all functional components, hooks and event handlers, you have to rely heavily on closures. Whenever we create a new function that accesses a variable within the scope of a component (such as a state or prop), we are most likely creating a closure.

Here's an example:

import { useState, useEffect } from "react";

function App({ id }) {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // Это замыкание, в котором заключена переменная count 
  };

  useEffect(() => {
    console.log(id); // Это замыкание, в котором заключён пропс id 
  }, [id]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

As a rule, such a structure in itself is not a problem. In the above example, the closures will be recreated at each rendering stage

App

, and old instances will be garbage collected. As a result, some unnecessary memory allocations and deallocations are possible, but in general these operations are quite fast.

True, as the application grows and with the transition to memoization techniques, for example, useMemo And useCallbackin order to avoid unnecessary rendering steps, you have to additionally monitor some things.

Closures and useCallback

By enabling memoization, we achieve increased rendering performance at the expense of additional memory consumption. IN

useCallback

will contain a reference to the function until the dependencies change. Let's look at this situation with an example:

import React, { useState, useCallback } from "react";

function App() {
  const [count, setCount] = useState(0);

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <ExpensiveChildComponent onMyEvent={handleEvent} />
    </div>
  );
}

In this example we want to avoid unnecessary rendering steps

ExpensiveChildComponent

. To do this, you can try to maintain a reference to the function in a stable form

handleEvent()

. We memoize

handleEvent()

with help

useCallback

only to reassign a new value when the state

count

will change. Then you can wrap

ExpensiveChildComponent

V

React.memo()

to avoid re-rendering in all cases where the parent element does the rendering

App

. It's okay for now.

But let's modify this example a little:

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}

function App() {
  const [count, setCount] = useState(0);
  const bigData = new BigObject();

  const handleEvent = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const handleClick = () => {
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClick} />
      <ExpensiveChildComponent2 onMyEvent={handleEvent} />
    </div>
  );
}

Can you guess what's happening?

Because the handleEvent() closures a variable count, it will contain a reference to the context object of this component. Moreover, let we never turn to bigData in function handleEvent()V handleEvent() will still contain a link to bigData. This is done through the component's context object.

All closures share a common context object that existed at the time they were created. Because the handleClick() closes to bigData on bigData this context object will be referenced. Thus, bigData will not be garbage collected as long as there is a reference to handleEvent(). This link will remain active until changed. count and will not be recreated handleEvent().

Infinite memory leak when combining useCallback with closures and large objects

Let's look at a final example where all of the above problems are taken to the extreme. This example is a shortened version of the code present in our application. Therefore, even if the example seems artificial, it demonstrates the general problem very well.

import { useState, useCallback } from "react";

class BigObject {
  public readonly data = new Uint8Array(1024 * 1024 * 10);
}

export const App = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  const bigData = new BigObject(); // 10 МБ данных

  const handleClickA = useCallback(() => {
    setCountA(countA + 1);
  }, [countA]);

  const handleClickB = useCallback(() => {
    setCountB(countB + 1);
  }, [countB]);

  // Этот код демонстрирует проблему
  const handleClickBoth = () => {
    handleClickA();
    handleClickB();
    console.log(bigData.data.length);
  };

  return (
    <div>
      <button onClick={handleClickA}>Increment A</button>
      <button onClick={handleClickB}>Increment B</button>
      <button onClick={handleClickBoth}>Increment Both</button>
      <p>
        A: {countA}, B: {countB}
      </p>
    </div>
  );
};

In this example, two event handlers are memoized:

handleClickA()

And

handleClickB()

. There is also a function here

handleClickBoth()

which both calls both event handlers and logs the length

bigData

.

Can you guess what will happen if you click the “Increment A” and “Increment B” buttons alternately?

Let's open the “Developer Tools” in the Chrome browser and see what happens in the Memory Inspector after we click each of these buttons five times:

Apparently

bigData

does not fall under garbage collection at all. With each press, memory consumption only increases. In our example, the application holds links to 11 instances

BigObject

, each 10 MB in size. One instance is created for the initial rendering, and another one is created with each click.

From the tree of held objects you can understand what is happening. Apparently we are creating a chain of duplicate links. Let's look at this situation step by step.

0. First rendering step:

During initial rendering App is being created closure scope, which contains references to all variables, since we use all of them in at least one closure. This concerns bigData, handleClickA() And handleClickB(). We refer to them in handleClickBoth(). Let's call the scope of the closure AppScope#0.

1. Click on “Increment A”:

  • When you first click on “Increment A” it will be recreated handleClickA()as we change countA – let's name the new instance handleClickA()#1.
  • handleClickB()#0 Not is recreated because countB does not change.
  • But at the same time, this means that handleClickB()#0 still holds the link to the previous one AppScope#0.
  • New copy handleClickA()#1 will hold the link to AppScope#1which holds a link to handleClickB()#0.

2. Click on “Increment B”:

  • The first time you click on “Increment B” it will be recreated handleClickB()as we change countB – let's name the new instance handleClickB()#1.
  • React doesn't recreate handleClickA()because the countA does not change.
  • Hence, handleClickB()#1 will hold the link to AppScope#2which holds a reference to handleClickA()#1which holds a link to AppScope#1which holds a link to handleClickB()#0.

3. Second click on “Increment A”:

This can result in an endless chain of closures that reference each other and never get garbage collected. All this time, a separate object hangs in the system bigData 10 MB, which is recreated at each rendering step.

The essence of the problem

The crux of the problem is that different

useCallback

, connecting to the same component, can refer to each other, as well as to other resource-intensive data through closure scopes. Closures are held in memory until the hooks are recreated

useCallback

. If more than one is connected to a component

useCallback

, then it becomes extremely difficult to judge what exactly is contained in memory and when this memory will be released. The more callbacks you have, the more likely you are to run into a problem.

Is this problem facing you?

Here are a few factors that may put you at greater risk of experiencing these types of problems:

  1. You have large components that are unlikely to ever be recreated, such as the application shell, which has a significant number of state details programmed into it.
  2. Do you use useCallbackto minimize re-rendering operations.
  3. From memoized functions you call other functions.
  4. You have to process large objects – for example, pictures or large arrays.

If you don't have to process any large objects, then perhaps references to a couple of extra lines or numbers are not a problem. Most of these cross-references between closures will resolve themselves when enough properties change. Just be aware that your application may consume more memory than you expected.

How to avoid memory leaks when working with closures and useCallback?

I'll give you some tips that will probably help you avoid such problems:

Tip 1: Keep the scope of your closures as small as possible

In JavaScript, it is very difficult to keep track of all the variables that are captured. The best way to avoid holding too many variables is to shrink the function itself around the closure. This means:

  1. Write smaller components. This will reduce the number of variables that will be in scope when a new closure is created.
  2. Write your own hooks. After all, in this case, any callback can only close on the scope of the hook function. Often this means that only the arguments to that function will be enclosed.

Tip 2: Try not to capture other closures, especially memoized ones.

While this advice may seem obvious, it's easy to fall into this trap with React. If you wrote small functions that call each other, then you should add the first one to this code useCallbackhow a chain reaction begins: in the scope of the component to be memoized, each of the functions begins to call the others.

Tip 3: Avoid memoization when it is not necessary.

useCallback And useMemo They are great for eliminating unnecessary re-rendering steps, but they come at a cost. Use them only if you are experiencing obvious performance issues due to rendering.

Tip 4 (Escape hatch): When working with large objects, use useRef.

You may then need to handle the object's lifecycle yourself and take care of the associated cleanup yourself. Not the best option, but better than memory leaks.

Conclusion

Closures are a pattern widely used in React. With their help, we ensure that other functions remember what the props and states in the scope were the last time a given component was displayed. But in combination with memoization tools, e.g.

useCallback

, this technique can lead to unexpected memory leaks, especially when working with large objects. To avoid such leaks, try to keep the scope of each closure as small as possible, and avoid memoization when it is not necessary. When working with large objects, try using

useRef

as a backup option.

Many thanks to David Glasser for his article. A surprising JavaScript memory leak found at Meteor, written in 2013. She became a guide for me.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *