🔁 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.
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 = useCallback(async () => {const canEat = await pet.canEat(selectedPetFood)if (canEat) {pet.eat(selectedPetFood)}}, []) // ← no dependencies — stale closure!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:
stepHere’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.
useCallbackconst 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! 🧪💡