This article is available in video format. Fewer details, but nice animations and voice instead of letters.
Also, this article is part of the "Advanced React" book. If you like it, you might like the book as well ๐
Table of content
- The problem
- JavaScript, scope, and closures
- The stale closure problem
- Stale closures in React: useCallback
- Stale closures in React: Refs
- Stale closures in React: React.memo
- Escaping the closure trap with Refs
Closures in JavaScript must be one of the most terrifying features of the language. Even the omniscient ChatGPT will tell you that. Itโs also probably one of the most hidden language concepts. We use it every time we write any React code, most of the time without even realizing it. But there is no getting away from them in the end: if we want to write complex and performant React apps, we have to know closures.
So letโs dive into yet another code mystery, and in the process learn:
- What closures are, how they appear, and why we need them.
- What a stale closure is, and why they occur.
- What the common scenarios in React are that cause stale closures, and how to fight them.
Warning: if you've never dealt with closures in React, this article might make your brain explode. Make sure to have enough chocolate with you to stimulate brain cells while you're reading this.
The problem
Imagine you're implementing a form with a few input fields. One of the fields is a very heavy component from some external library. You don't have access to its internals, so you can't fix its performance problems. But you really need it in your form, so you decide to wrap it in React.memo
, to minimize its re-renders when the state in your form changes. Something like this:
const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
const [value, setValue] = useState();
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo />
</>
);
};
So far, so good. This Heavy component accepts just one string prop, let's say title
, and an onClick
callback. This one is triggered when you click a "done" button inside that component. And you want to submit your form data when this click happens. Also easy enough: just pass the title
and onClick
props to it.
const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// submit our form data here
console.log(value);
};
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
And now you'll face a dilemma. As we know, every prop on a component wrapped in React.memo
needs to be either a primitive value or persistent between re-renders. Otherwise, memoization won't work. So technically, we need to wrap our onClick
in useCallback
:
const onClick = useCallback(() => {
// submit data here
}, []);
But also, we know that the useCallback
hook should have all dependencies declared in its dependencies array. So if we want to submit our form data inside, we have to declare that data as a dependency:
const onClick = useCallback(() => {
// submit data here
console.log(value);
// adding value to the dependency
}, [value]);
And here's the dilemma: even though our onClick
is memoized, it still changes every time someone types in our input. So our performance optimization is useless.
Okay, fair enough, let's look for other solutions. React.memo
has a thing called comparison function. It allows us more granular control over props comparison in React.memo
. Normally, React compares all "before" props with all "after" props by itself. If we provide this function, it will rely on its return result instead. If it returns true
, then React will know that props are the same, and the component shouldn't be re-rendered. Sounds exactly what we need.
We only have one prop that we care about updating there, our title
, so it's not going to be that complicated:
const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);
The code for the entire form will then look something like this:
const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);
const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// submit our form data here
console.log(value);
};
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
And it worked! We type something in the input, the heavy component doesn't re-render, and performance doesn't suffer.
Except for one tiny problem: it doesn't actually work. If you type something in the input and then press that button, the value
that we log in onClick
is undefined
. But it can't be undefined, the input works as expected, and if I add console.log
outside of onClick
it logs it correctly. Just not inside onClick
.
// those one logs it correctly
console.log(value);
const onClick = () => {
// this is always undefined
console.log(value);
};
You can play around with the full example here:
What's going on?
This is known as the "stale closure" problem. And in order to fix it, we first need to dig a bit into probably the most feared topic in JavaScript: closures and how they work.
JavaScript, scope, and closures
Let's start with functions and variables. What happens when we declare a function in JavaScript, either via normal declaration or via arrow function?
function something() {
//
}
const something = () => {};
By doing that, we created a local scope: an area in our code where variables declared inside won't be visible from the outside.
const something = () => {
const value = 'text';
};
console.log(value); // not going to work, "value" is local to "something" function
This happens every time we create a function. A function created inside another function will have its own local scope, invisible to the function outside.
const something = () => {
const inside = () => {
const value = 'text';
};
console.log(value); // not going to work, "value" is local to "inside" function
};
In the opposite direction, however, it's an open road. The inner-most function will "see" all the variables declared outside.
const something = () => {
const value = 'text';
const inside = () => {
// perfectly fine, value is available here
console.log(value);
};
};
This is achieved by creating what is known as "closure". The function inside "closes" over all the data from the outside. It's essentially a snapshot of all the "outside" data frozen in time stored separately in memory.
If instead of creating that value
inside the something
function, I pass it as an argument and return the inside
function:
const something = (value) => {
const inside = () => {
// perfectly fine, value is available here
console.log(value);
};
return inside;
};
We'll get this behavior:
const first = something('first');
const second = something('second');
first(); // logs "first"
second(); // logs "second"
We call our something
function with the value "first" and assign the result to a variable. The result is a reference to a function declared inside. A closure is formed. From now on, as long as the first
variable that holds that reference exists, the value "first" that we passed to it is frozen, and the inside
function will have access to it.
The same story with the second call: we pass a different value, a closure is formed, and the function returned will forever have access to that variable.
This is true for any variable declared locally inside the something
function:
const something = (value) => {
const r = Math.random();
const inside = () => {
// ...
};
return inside;
};
const first = something('first');
const second = something('second');
first(); // logs random number
second(); // logs another random number
It's like taking a photograph of some dynamic scene: as soon as you press the button, the entire scene is "frozen" in the picture forever. The next press of the button will not change anything in the previously taken picture.
In React, we're creating closures all the time without even realizing it. Every single callback function declared inside a component is a closure:
const Component = () => {
const onClick = () => {
// closure!
};
return <button onClick={onClick} />;
};
Everything in useEffect
or useCallback
hook is a closure:
const Component = () => {
const onClick = useCallback(() => {
// closure!
});
useEffect(() => {
// closure!
});
};
All of them will have access to state, props, and local variables declared in the component:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// perfectly fine
console.log(state);
});
useEffect(() => {
// perfectly fine
console.log(state);
});
};
Every single function inside a component is a closure since a component itself is just a function.
The stale closure problem
But all of the above, although slightly unusual if you're coming from a language that doesn't have closures, is still relatively straightforward. You create a few functions a few times, and it becomes natural. It's even unnecessary to understand the concept of "closure" to write apps in React for years.
So what is the problem, then? Why are closures one of the most terrifying things in JavaScript and a source of pain for so many developers?
It's because closures live for as long as a reference to the function that caused them exists. And the reference to a function is just a value that can be assigned to anything. Let's twist our brains a bit. Here's our function from above, that returns a perfectly innocent closure:
const something = (value) => {
const inside = () => {
console.log(value);
};
return inside;
};
But the inside
function is re-created there with every something
call. What will happen if I decide to fight it and cache it? Something like this:
const cache = {};
const something = (value) => {
if (!cache.current) {
cache.current = () => {
console.log(value);
};
}
return cache.current;
};
On the surface, the code seems harmless. We just created an external variable named cache
and assigned our inside function to the cache.current
property. Now, instead of this function being re-created every time, we just return the already saved value.
However, if we try to call it a few times, we'll see a weird thing:
const first = something('first');
const second = something('second');
const third = something('third');
first(); // logs "first"
second(); // logs "first"
third(); // logs "first"
No matter how many times we call the something
function with different arguments, the logged value is always the first one!
We just created what is known as the "stale closure". Every closure is frozen at the point when it's created. When we first called the something
function, we created a closure that has "first" in the value
variable. And then, we saved it in an object that sits outside of the something
function.
When we call the something
function the next time, instead of creating a new function with a new closure, we return the one that we created before. The one that was frozen with the "first" variable forever.
In order to fix this behavior, we'd want to re-create the function and its closure every time the value
changes. Something like this:
const cache = {};
let prevValue;
const something = (value) => {
// check whether the value has changed
if (!cache.current || value !== prevValue) {
cache.current = () => {
console.log(value);
};
}
// refresh it
prevValue = value;
return cache.current;
};
Save the value in a variable so that we can compare the next value with the previous one. And then refresh the cache.current
closure if the variable has changed.
Now it will be logging variables correctly, and if we compare functions with the same value, that comparison will return true
:
const first = something('first');
const anotherFirst = something('first');
const second = something('second');
first(); // logs "first"
second(); // logs "second"
console.log(first === anotherFirst); // will be true
Play around with the code here:
Stale closures in React: useCallback
We just implemented almost exactly what the useCallback
hook does for us! Every time we use useCallback
, we create a closure, and the function that we pass to it is cached:
// that inline function is cached exactly as in the section before
const onClick = useCallback(() => {}, []);
If we need access to state or props inside this function, we need to add them to the dependencies array:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// access to state inside
console.log(state);
// need to add this to the dependencies array
}, [state]);
};
This dependencies array is what makes React refresh that cached closure, exactly as we did when we compared value !== prevValue
. If I forget about that array, our closure becomes stale:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// state will always be the initial state value here
// the closure is never refreshed
console.log(state);
// forgot about dependencies
}, []);
};
And every time I trigger that callback, all that will be logged is undefined
.
Play around with the code here:
Stale closures in React: Refs
The second most common way to introduce the stale closure problem, after useCallback
and useMemo
hooks, is Refs.
What will happen if I try to use Ref for that onClick
callback instead of useCallback
hook? It's sometimes what the articles on the internet recommend doing to memoize props on components. On the surface, it does look simpler: just pass a function to useRef
and access it through ref.current
. No dependencies, no worries.
const Component = () => {
const ref = useRef(() => {
// click handler
});
// ref.current stores the function and is stable between re-renders
return <HeavyComponent onClick={ref.current} />;
};
However. Every function inside our component will form a closure, including the function that we pass to useRef
. Our ref will be initialized only once when it's created and never updated by itself. It's basically the logic that we created at the beginning. Only instead of value
, we pass the function that we want to preserve. Something like this:
const ref = {};
const useRef = (callback) => {
if (!ref.current) {
ref.current = callback;
}
return ref.current;
};
So, in this case, the closure that was formed at the very beginning, when the component was just mounted, will be preserved and never refreshed. When we try to access the state or props inside that function stored in Ref, we'll only get their initial values:
const Component = ({ someProp }) => {
const [state, setState] = useState();
const ref = useRef(() => {
// both of them will be stale and will never change
console.log(someProp);
console.log(state);
});
};
To fix this, we need to ensure that we update that ref value every time something that we try to access inside changes. Essentially, we need to implement what the dependencies array functionality does for the useCallback
hook.
const Component = ({ someProp }) => {
// initialize ref - creates closure!
const ref = useRef(() => {
// both of them will be stale and will never change
console.log(someProp);
console.log(state);
});
useEffect(() => {
// update the closure when state or props change
ref.current = () => {
console.log(someProp);
console.log(state);
};
}, [state, someProp]);
};
Play around with the code here:
Stale closures in React: React.memo
And finally, we're back to the beginning of the article and the mystery that initiated all this. Let's take a look at the problematic code again:
const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);
const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// submit our form data here
console.log(value);
};
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
Every time we click on the button, we log "undefined". Our value
inside onClick
is never updated. Can you tell why now?
It's a stale closure again, of course. When we create onClick
, the closure is first formed with the default state value, i.e., "undefined". We pass that closure to our memoized component, along with the title
prop. Inside the comparison function, we compare only the title
. It never changes, it's just a string. The comparison function always returns true
, HeavyComponent
is never updated, and as a result, it holds the reference to the very first onClick
closure, with the frozen "undefined" value.
Now that we know the problem, how do we fix it? Easier said than done hereโฆ
Ideally, we should compare every prop in the comparison function, so we need to include onClick
there:
(before, after) => {
return (
before.title === after.title &&
before.onClick === after.onClick
);
};
However, in this case, it would mean we're just reimplementing the React default behavior and doing exactly what React.memo
without the comparison function does. So we can just ditch it and leave it only as React.memo(HeavyComponent)
.
But doing that means that we need to wrap our onClick
in useCallback
. But it depends on the state, so it will change with every keystroke. We're back to square one: our heavy component will re-render on every state change, exactly what we tried to avoid.
We could play around with composition and try to extract and isolate either state or HeavyComponent
. But it won't be easy: input and HeavyComponent
both depend on that state.
We can try many other things. But we don't have to do any heavy refactorings to escape that closures trap. There is one cool trick that can help us here.
Escaping the closure trap with Refs
This trick is absolutely mind-blowing: it's very simple, but it can forever change how you memoize functions in React. Or maybe not... In any case, it might be useful, so let's dive into it.
Let's get rid of the comparison function in our React.memo
and onClick
implementation for now. Just a pure component with state and memoized HeavyComponent
:
const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
const [value, setValue] = useState();
return (
<>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
<HeavyComponentMemo title="Welcome to the form" onClick={...} />
</>
);
}
Now we need to add an onClick
function that is stable between re-renders but also has access to the latest state without re-creating itself.
We're going to store it in Ref, so let's add it. Empty for now:
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
};
In order for the function to have access to the latest state, it needs to be re-created with every re-render. There is no getting away from it, it's the nature of closures, nothing to do with React. We're supposed to modify Refs inside useEffect
, not directly in render, so let's do that.
const Form = () => {
const [value, setValue] = useState();
// adding an empty ref
const ref = useRef();
useEffect(() => {
// our callback that we want to trigger
// with state
ref.current = () => {
console.log(value);
};
// no dependencies array!
});
};
useEffect
without the dependency array will be triggered on every re-render. Which is exactly what we want. So now in our ref.current
we have a closure that is recreated with every re-render, so the state that is logged there is always the latest.
But we can't just pass that ref.current
to the memoized component. That value will differ with every re-render, so memoization just won't work.
const Form = () => {
const ref = useRef();
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
return (
<>
{/* Can't do that, will break memoization */}
<HeavyComponentMemo onClick={ref.current} />
</>
);
};
So instead, let's create a small empty function wrapped in useCallback
with no dependencies for that.
const Form = () => {
const ref = useRef();
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
const onClick = useCallback(() => {
// empty dependency! will never change
}, []);
return (
<>
{/* Now memoization will work, onClick never changes */}
<HeavyComponentMemo onClick={onClick} />
</>
);
};
Now, memoization works perfectly - the onClick
never changes. One problem, though: it does nothing.
And here's the magic trick: all we need to make it work is to call ref.current
inside that memoized callback:
useEffect(() => {
ref.current = () => {
console.log(value);
};
});
const onClick = useCallback(() => {
// call the ref here
ref.current();
// still empty dependencies array!
}, []);
Notice how ref
is not in the dependencies of the useCallback
? It doesn't need to be. ref
by itself never changes. It's just a reference to a mutable object that the useRef
hook returns.
But when a closure freezes everything around it, it doesn't make objects immutable or frozen. Objects are stored in a different part of the memory, and multiple variables can contain references to exactly the same object.
const a = { value: 'one' };
// b is a different variable that references the same object
const b = a;
If I mutate the object through one of the references and then access it through another, the changes will be there:
a.value = 'two';
console.log(b.value); // will be "two"
In our case, even that doesn't happen: we have exactly the same reference inside useCallback
and inside useEffect
. So when we mutate the current
property of the ref
object inside useEffect
, we can access that exact property inside our useCallback
. This property happens to be a closure that captured the latest state data.
The full code will look like this:
const Form = () => {
const [value, setValue] = useState();
const ref = useRef();
useEffect(() => {
ref.current = () => {
// will be latest
console.log(value);
};
});
const onClick = useCallback(() => {
// will be latest
ref.current?.();
}, []);
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome closures"
onClick={onClick}
/>
</>
);
};
Now, we have the best of both worlds: the heavy component is properly memoized and doesn't re-render with every state change. And the onClick
callback on it has access to the latest data in the component without ruining memoization. We can safely send everything we need to the backend now!
Play around with the fixed example here:
Hopefully, all of this made sense, and closures are now easy-peasy for you. A few things to remember about closures, before you go:
- Closures are formed every time a function is created inside another function.
- Since React components are just functions, every function created inside forms a closure, including such hooks as
useCallback
anduseRef
. - When a function that forms a closure is called, all the data around it is "frozen", like a snapshot.
- To update that data, we need to re-create the "closed" function. This is what dependencies of hooks like
useCallback
allow us to do. - If we miss a dependency, or don't refresh the closed function assigned to
ref.current
, the closure becomes "stale". - We can escape the "stale closure" trap in React by taking advantage of the fact that Ref is a mutable object. We can mutate
ref.current
outside of the stale closure, and then access it inside. Will be the latest data.
The video based on the material in this article is available below. It's less detailed but has nice animations and visuals, so it could be useful to solidify the knowledge.
This article is part of the "Advanced React" book. If you like it, you might like the book as well ๐
Originally published at https://www.developerway.com. The website has more articles like this.
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Top comments (0)