When you're developing a React app, everything seems to be going smoothly until all of a sudden, one of your component's behaviors seems to be stuck in a loop. The values you're seeing are outdated, your UI isn't responding correctly, and you're left scratching your head wondering what went wrong.
This frustrating scenario is what we call the "stale values" problem in React, and it's more common than you might think. Whether you're handling events, managing side effects, or dealing with async operations, stale values can sneak into your code and cause subtle bugs that are difficult to track down.
The good news? There are proven solutions to handle these scenarios elegantly. In this tutorial, we'll dive deep into understanding stale values in React.
Prerequisite
Some hooks are talked about in this article, and I assume you already know how to use it. If it’s not the case, here are some blog posts I wrote that can give you an insight:
What is a stale value
The Stale Value in React refers to a scenario where the value of a variable or state does not reflect the most recent update, but rather an older, “Stale” version.
How stale values happen
React components re-render whenever their state changes. However, when you capture state or props inside effects (useEffect
), callbacks, or closures, these functions may continue referencing outdated values even after the state or props have changed — unless they're explicitly configured to track the current values.
In React, useEffect and other hooks allow specifying a dependency array. If the state or props are dependencies and are not included in this array, your effect might not run again when they update, leading to stale values.
When using
useEffect
or similar hooks that depend on specific props or state, always ensure those dependencies are listed in the dependency array.Practical case with an audio player
Take the case where we want to handle a media player in the code sandbox below.
If we want to hit the play
/ pause
button with the space key in the keyboard, we can use a useEffect to handle that.
React.useEffect(() => {
function handleKeyDown(event) {
if (event.code === 'Space') {
// TODO: Play or pause
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
We start our subscription, listening for keydown
events, and checking if they've pressed the space bar key. Our cleanup function unsubscribes from this event. Because we've specified an empty dependency array, this event listener runs for the entire component lifetime.
How do we play or pause the song? Well, we have this chunk of code from earlier:
<button
onClick={() => {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}}
>
The solution, is to add a second useEffect
hook. This hook has 1 job: to make sure that the audio DOM node’s internal state is kept in sync with the isPlaying
state variable:
React.useEffect(() => {
if (isPlaying) {
audioRef.current.play();
} else {
audioRef.current.pause();
}
}, [isPlaying]);
Then we can update our button to only toggle the state variable:
<button
onClick={() => {
// This work is no longer necessary:
//
// if (isPlaying) {
// audioRef.current.pause();
// } else {
// audioRef.current.play();
// }
setIsPlaying(!isPlaying);
}}
>
And we can update the isPlaying
state within our keyDown
callback as well:
React.useEffect(() => {
function handleKeyDown(event) {
if (event.code === 'Space') {
setIsPlaying(!isPlaying);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
Now, when we do this, we get a lint warning:

The problem with our code is that our effect only runs after the 1st render. It means we only have access to the very first snapshot, where isPlaying
is permanently set to false
.
Every time the user hits the space bar key, we call setIsPlaying(!false)
. This means that the keyboard shortcut can start the song, but it can't stop it.

To run the player, you have to click on the iframe on the right side of the code sandbox.
There are two potential solutions to this problem:
- Adding the dependency
- Using the callback escape hatch
Resolve stale value with adding dependency
To solve that problem, we can add the isPlaying
state to the dependency array:
React.useEffect(() => {
function handleKeyDown(event) {
if (event.code === 'Space') {
setIsPlaying(!isPlaying);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isPlaying]); // 👈🏼 Add the dependency here
In this solution, we call our cleanup function and re-run the effect whenever isPlaying
changes. It means that we’re constantly “refreshing” our key down callback so that it always has access to the most recent snapshot.

In this particular scenario, this is no issue at all; it's quick to add / remove event listeners.
But what if the subscription/unsubscription process was slow? Is there any way to access the freshest value of isPlaying
without adding it to the dependency array?
Resolve stale value with state updater
The second alternative, we will use the state updater, and here’s it looks like:
React.useEffect(() => {
function handleKeyDown(event) {
if (event.code === 'Space') {
setIsPlaying(currentIsPlaying => {
return !currentIsPlaying;
});
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
When we pass a function to our state-setter function, React will invoke this function for us, and whatever we return becomes the new state. When React invokes this function for us, it provides the current value of the state as an argument.

This is the freshest value, plucked straight from the component instance.
With this workaround, we're able to keep a single event handler running for the entire component lifecycle.
They both work equally well in most situations. There's a hypothetical performance benefit to this approach, but it's negligible in most cases.
It's worth becoming comfortable with both approaches. Once you understand them both, you can pick whichever you like best.
I recommend using
currentX
as the name for the parameter in the callback function. So if the state variable is count
, I'd make the parameter currentCount
. I also occasionally use currentValue
. The goal is to make clear that we're not referencing the potentially stale variable from the snapshot.
🔍. Similar posts
Write Cleaner and Faster Unit Tests with AssertJ as a Java Developer
12 May 2025
Understanding the useRef hook in React
28 Apr 2025
React Component Cleanup
12 Mar 2025