DEV Community

Learcise
Learcise

Posted on

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

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

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode
  • 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]);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
  • 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>;
}

Enter fullscreen mode Exit fullscreen mode
  • 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);
});

Enter fullscreen mode Exit fullscreen mode

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.”

Top comments (0)