How can you get burned with useCallback and closures?
![image](https://habrastorage.org/webt/or/vz/zs/orvzzsragxyifkfpj2z11rm_woy.png)
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
.
![](https://habrastorage.org/webt/iw/fz/la/iwfzla_6bvriw2nuhutnpxyyayo.png)
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 useCallback
in 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()
.
![](https://habrastorage.org/webt/ii/ul/wh/iiulwh8_pbtixcxitxqpazghec8.png)
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:
![](https://habrastorage.org/webt/gk/u1/2y/gku12ygop_srrpcw_qqmpj-o9ds.png)
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
.
![](https://habrastorage.org/webt/sd/jr/ky/sdjrkyvl-7x4brljc3230rvgxze.png)
1. Click on “Increment A”:
- When you first click on “Increment A” it will be recreated
handleClickA()
as we changecountA
– let's name the new instancehandleClickA()#1
. handleClickB()#0
Not is recreated becausecountB
does not change.- But at the same time, this means that
handleClickB()#0
still holds the link to the previous oneAppScope#0
. - New copy
handleClickA()#1
will hold the link toAppScope#1
which holds a link tohandleClickB()#0
.
![](https://habrastorage.org/webt/9z/tu/wi/9ztuwi-e_iv4q8i7gmlrkkgyruy.png)
2. Click on “Increment B”:
- The first time you click on “Increment B” it will be recreated
handleClickB()
as we changecountB
– let's name the new instancehandleClickB()#1
. - React doesn't recreate
handleClickA()
because thecountA
does not change. - Hence,
handleClickB()#1
will hold the link toAppScope#2
which holds a reference tohandleClickA()#1
which holds a link toAppScope#1
which holds a link tohandleClickB()#0
.
![](https://habrastorage.org/webt/iq/51/-e/iq51-evudtmjdzihyom6brectog.png)
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.
![](https://habrastorage.org/webt/ke/uj/o_/keujo_ln-cp1nuai1pvnoe_7yfa.png)
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:
- 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.
- Do you use
useCallback
to minimize re-rendering operations. - From memoized functions you call other functions.
- 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:
- Write smaller components. This will reduce the number of variables that will be in scope when a new closure is created.
- 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 useCallback
how 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.