🔁 The Latest Ref Pattern in React: Solving Stale Closure Problems Without Breaking Hooks
React Hooks gave us a powerful and expressive way to write components. But they also introduced a subtle shift in behavior: functions within your component “remember” the values from when they were created. This is due to how JavaScript closures work.
Let’s illustrate this with an example.
In class components, accessing the latest values was straightforward:
class PetFeeder extends React.Component {state = { selectedPetFood: null }feedPet = async () => {const canEat = await this.props.pet.canEat(this.state.selectedPetFood)if (canEat) {this.props.pet.eat(this.state.selectedPetFood)}}render() {return (<div><PetFoodChooseronSelection={selectedPetFood => this.setState({ selectedPetFood })}/><button onClick={this.feedPet}>Feed {this.props.pet.name}</button></div>)}}
Now imagine the user selects “worms” and clicks “Feed.” While waiting for canEat(worms)
to return, they change their selection to “grass.” When the check completes, we might mistakenly feed the pet grass instead of worms — even though the check was for worms.
This kind of bug is subtle, hard to reproduce, and very real in asynchronous code.
You could solve this by caching the props/state at the time the function runs:
feedPet = async () => {const { pet } = this.propsconst { selectedPetFood } = this.stateconst canEat = await pet.canEat(selectedPetFood)if (canEat) {pet.eat(selectedPetFood)}}
This ensures the values being used are snapshotted at the time of the action — no surprises.
Hooks changed the game. Now, closures capture whatever values existed when the function was defined.
Here’s the same PetFeeder
component using hooks:
function PetFeeder({ pet }) {const [selectedPetFood, setSelectedPetFood] = useState(null)const feedPet = async () => {const canEat = await pet.canEat(selectedPetFood)if (canEat) {pet.eat(selectedPetFood)}}return (<div><PetFoodChooser onSelection={food => setSelectedPetFood(food)} /><button onClick={feedPet}>Feed {pet.name}</button></div>)}
At first glance, this seems fine — and it usually is. But if feedPet
is used inside an effect or another memoized callback, and selectedPetFood
changes, the stale value could be used.
Sometimes, you want to use the latest value without re-creating your function every time the value changes.
Here’s how:
function PetFeeder({ pet }) {const [selectedPetFood, setSelectedPetFood] = useState(null)const latestPetRef = useRef(pet)const latestSelectedPetFoodRef = useRef(selectedPetFood)useEffect(() => {latestPetRef.current = petlatestSelectedPetFoodRef.current = selectedPetFood}, [pet, selectedPetFood])const feedPet = async () => {const canEat = await latestPetRef.current.canEat(latestSelectedPetFoodRef.current)if (canEat) {latestPetRef.current.eat(latestSelectedPetFoodRef.current)}}return (<div><PetFoodChooser onSelection={setSelectedPetFood} /><button onClick={feedPet}>Feed {pet.name}</button></div>)}
This pattern lets you define feedPet
just once, and always get the current values of pet
and selectedPetFood
.
There are real-world use cases where you need to:
useEffect
.One popular example is react-query
, where internal logic can call user-defined callbacks from within effects — without needing them in a dependency array.
Let’s look at a debounced counter where this pattern becomes essential.
You have:
step
Here’s the naive setup:
const increment = () => setCount(c => c + step)const debouncedIncrement = useDebounce(increment, 3000)
Every time step
changes, a new increment
function is created, so the debounce timer gets reset and doesn’t cancel properly.
useCallback
const increment = useCallback(() => setCount(c => c + step), [step])const debouncedIncrement = useDebounce(increment, 3000)
Still no luck — a new increment
gets created when step
changes.
Inside your useDebounce
hook, store the latest version of the callback in a ref:
function useDebounce(callback, delay) {const latestCallbackRef = useRef(callback)useEffect(() => {latestCallbackRef.current = callback}, [callback])return useMemo(() => {return debounce(() => latestCallbackRef.current(), delay)}, [delay])}
Now, no matter how often callback
changes, the debounced function always has access to the most recent version of it — and the debounce timer stays consistent.
Yes, this pattern is powerful — but it comes with responsibility:
Want to experiment with this pattern? Try building a debounced search input or autocomplete box using useDebounce
and the Latest Ref Pattern — you’ll see its power firsthand.
Happy coding! 🧪💡
Quick Links
Legal Stuff
Social Media