Skip to content

How to Access Current State in Async Callbacks Without Stale Closures in React

Problem

When I tried to access state inside an async callback in React, I got stale values. The callback was “closing over” the old state value from when it was created, not the current value.

Here’s what happened:

Browser Console
User typed: "Hello World"
Clicked send button
3 seconds later...
Alert showed: "Hello" // Wrong! Should show "Hello World"

The user kept typing after clicking send, but the alert showed the old text. This is the stale closure problem.

Environment

  • React 18.2
  • Browser: Chrome 120

What Happened?

I was building a chat component with a delayed send feature. When the user clicks send, the message should be sent after 3 seconds. But during those 3 seconds, the user might keep typing.

Here’s my original code:

Chat.jsx
import { useState } from 'react';
function Chat() {
const [text, setText] = useState('');
function handleSend() {
setTimeout(() => {
alert('Sending: ' + text); // Problem: shows old text
}, 3000);
}
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send in 3 seconds</button>
</>
);
}
export default Chat;

I can explain the key parts:

  • text state holds the input value
  • handleSend creates a 3-second timeout
  • The timeout callback reads text

But when I tested it:

  1. I typed “Hello”
  2. I clicked send
  3. I typed ” World” (total: “Hello World”)
  4. After 3 seconds, the alert showed “Hello” (the stale value)

The problem is that text inside the setTimeout callback is captured when handleSend is called. It doesn’t update when state changes.

How to Solve It?

First Attempt: Add text to Dependencies?

I thought about adding text to some dependency array, but setTimeout isn’t inside useEffect. There’s no dependency array here.

This approach doesn’t apply.

Second Attempt: Use a Ref

I read the React documentation and found the ref-syncing pattern. The idea is:

  1. Keep state for rendering (triggers re-renders)
  2. Keep a ref for async callbacks (always has latest value)
  3. Sync them with useEffect

Here’s the solution:

Chat.jsx
import { useState, useRef, useEffect } from 'react';
function Chat() {
const [text, setText] = useState('');
const textRef = useRef(text);
// Keep ref in sync with state
useEffect(() => {
textRef.current = text;
}, [text]);
function handleSend() {
setTimeout(() => {
alert('Sending: ' + textRef.current); // Now shows current text
}, 3000);
}
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send in 3 seconds</button>
</>
);
}
export default Chat;

Now when I test it:

  1. I typed “Hello”
  2. I clicked send
  3. I typed ” World”
  4. After 3 seconds, the alert showed “Hello World” (the current value)

It works!

The Reason

Why does the ref-syncing pattern work?

Why stale closures happen:

When you create a function in JavaScript, it “closes over” the variables in its scope. The setTimeout callback closes over text at the moment handleSend runs. Even if text changes later, the callback still sees the old value.

Think of it like a snapshot. The callback takes a photo of text when created. It doesn’t see updates.

Why refs work:

Refs are different. A ref is a mutable object that persists across renders. When you read textRef.current, you’re reading the current value of that mutable property, not a closed-over variable.

The flow:

  1. User types -> setText updates state
  2. State change triggers re-render
  3. useEffect runs -> textRef.current = text
  4. Ref now holds the latest value
  5. setTimeout callback reads textRef.current at execution time

The key difference: the callback doesn’t close over the text value. It closes over the ref object (which is stable) and reads its current property when it runs.

More Examples

Speech Synthesis Player

I had a similar problem with a text-to-speech component. The onend callback needed to check if the user had paused playback.

SpeechPlayer.jsx
import { useState, useRef, useEffect } from 'react';
function SpeechPlayer({ text }) {
const [isPlaying, setIsPlaying] = useState(false);
const isPlayingRef = useRef(isPlaying);
// Keep ref in sync
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
if (!text) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.onend = () => {
// Check CURRENT playing status
// Works even if user clicked pause during playback
if (isPlayingRef.current) {
console.log('Auto-playing next...');
}
};
if (isPlaying) {
speechSynthesis.speak(utterance);
}
return () => {
speechSynthesis.cancel();
};
}, [text, isPlaying]);
return (
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
);
}
export default SpeechPlayer;

Without the ref, isPlaying in onend would be stuck at whatever value it had when the effect ran.

Timer with Dynamic Increment

Here’s a timer where the increment amount can change:

Timer.jsx
import { useState, useRef, useEffect } from 'react';
function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const incrementRef = useRef(increment);
// Sync ref with state
useEffect(() => {
incrementRef.current = increment;
}, [increment]);
useEffect(() => {
const id = setInterval(() => {
// Always uses latest increment value
setCount(c => c + incrementRef.current);
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps - effect runs once
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setIncrement(i => i + 1)}>
Increment by: {increment}
</button>
</div>
);
}
export default Timer;

The interval runs once with empty deps. But incrementRef.current always has the latest increment value.

Common Mistakes

Mistake 1: Reading ref.current in render

Mistake1.jsx
// Wrong - ref changes don't trigger re-renders
function Counter() {
const countRef = useRef(0);
return <div>{countRef.current}</div>;
}

Use state for values that affect the UI. Use refs for values needed in callbacks.

Mistake 2: Forgetting to sync

Mistake2.jsx
// Wrong - ref never updates
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Missing: useEffect to sync countRef.current = count

Mistake 3: Adding ref to dependency arrays

Mistake3.jsx
// Wrong - refs are stable, no need for deps
useEffect(() => {
doSomething(countRef.current);
}, [countRef]); // Unnecessary

Refs are stable across renders. Don’t add them to dependency arrays.

Alternative: useEffectEvent (React 19)

If you’re using React 19 or later, there’s a cleaner solution: useEffectEvent.

ChatRoom.jsx
import { useState, useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
// Always sees latest theme
});
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
connection.on('connected', onConnected);
return () => connection.disconnect();
}, [roomId]); // Only roomId in deps
return <div>Connected to {roomId}</div>;
}

useEffectEvent creates a callback that always sees the latest props and state, but doesn’t need to be in the dependency array.

For React 18 and earlier, the ref-syncing pattern is the way to go.

Summary

In this post, I showed how to fix stale closure issues in React async callbacks using the ref-syncing pattern. The key point is to maintain both state (for rendering) and a ref (for accessing latest values), keeping them synced with useEffect.

The pattern:

  1. Create state for rendering: const [value, setValue] = useState(initial)
  2. Create ref for callbacks: const valueRef = useRef(value)
  3. Sync them: useEffect(() => valueRef.current = value, [value])
  4. Use value in render, valueRef.current in callbacks

This works for setTimeout, setInterval, event listeners, and any async callback that needs access to current state.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments