Updating But Not Reflecting!? React’s Common State ‘Stale Closure’ Pitfall



This content originally appeared on DEV Community and was authored by Learcise

Have You Ever Experienced This When Using React?

  • You call setState, but inside an event handler the value is still old
  • Inside a setInterval, reading state always gives you the initial value
  • “It should be updating, but nothing changes!”

One culprit behind this is the stale closure problem.

In this article, we’ll cover:

  1. Basics of scope and closures
  2. Why stale closures happen in React
  3. Typical examples where it occurs
  4. Ways to fix it
  5. The role of useRef

1. A Refresher on Scope and Closures

What Is Scope?

Scope is “the range where a variable lives.”

For example, variables created inside a function cannot be accessed from outside.

function foo() {
  const x = 10;
  console.log(x); // 10
}
foo();

console.log(x); // ❌ Error: x doesn’t exist here

What Is a Closure?

A closure is “the mechanism where a function remembers the variables from the environment in which it was created.”

function outer() {
  const message = "Hello";

  function inner() {
    console.log(message);
  }

  return inner;
}

const fn = outer();
fn(); // "Hello"

Normally, when outer finishes, message should disappear.

But since inner remembers the scope at the time it was created, it can still access message.

Think of a function as a time capsule carrying a box of variables from the moment it was created.

2. Why Stale Closures Happen in React

React components are functions, so a new scope is created on every render.

For example:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log("count:", count); // ← stays 0 forever
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

  • Clicking the button updates count
  • But inside setInterval, count remains the initial 0

That’s because the closure created in useEffect([]) holds onto the initial scope forever.

In other words, you’re stuck with a stale closure — a closure trapped with old scope.

3. Common Scenarios Where It Happens

  • Callbacks for setInterval / setTimeout
  • Loops with requestAnimationFrame
  • Event handlers from WebSocket or addEventListener
  • Async callbacks (then, async/await) reading state

The common theme: a function registered once keeps living for a long time.

4. How to Fix It

① Specify Dependencies Correctly

The simplest fix is to include state in the dependency array of useEffect.

useEffect(() => {
  const id = setInterval(() => {
    console.log("count:", count); // always the latest value
  }, 1000);
  return () => clearInterval(id);
}, [count]);

But beware: the effect re-subscribes on every change, which may affect performance or resource management.

② Use Functional setState

For state updates, you can use the functional form of setState, which always receives the latest value regardless of closures.

setCount(prev => prev + 1);

This avoids stale closures and is the safest pattern.

③ Use useRef (a powerful stale closure workaround)

Here’s where useRef shines.

5. How useRef Helps Avoid Stale Closures

What Is useRef?

useRef creates a box that persists across renders.

const ref = useRef(0);

ref.current = 123;
console.log(ref.current); // 123

  • Store values in ref.current
  • Updating it does not trigger re-renders
  • Useful not only for DOM refs, but also for persisting variables

Example: Fixing a Stale Closure

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // Mirror latest count into ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log("Latest count:", countRef.current); // always up to date
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

  • Inside setInterval, read countRef.current
  • No more stale closure — always the latest value

Advanced: Storing Functions in useRef

You can also store functions inside a ref to always call the latest logic.

const callbackRef = useRef<(val: number) => void>(() => {});

useEffect(() => {
  callbackRef.current = (val: number) => {
    console.log("Latest count:", count, "val:", val);
  };
}, [count]);

// Example: called from external events
socket.on("message", (val) => {
  callbackRef.current(val);
});

6. Summary

  • Closures remember the scope from when the function was created
  • Stale closures are closures stuck with old scope
  • In React, they often show up in intervals, event handlers, async callbacks, etc.
  • Solutions:
    1. Correctly specify dependencies
    2. Use functional setState
    3. Use useRef to persist latest values or functions

👉 A stale closure is like a “time-traveling bug in React.”

A function keeps carrying an old scope into the future — and that’s why your state “doesn’t update.”


This content originally appeared on DEV Community and was authored by Learcise