React Event Listeners: Escaping the Stale Closure Trap

In this post, you'll learn how to avoid one of React's most notorious gotchas: stale closures in event listeners. We'll explore why event listeners cause memory leaks, why your counter gets stuck at 1, and how to fix both problems using useEffect and useRef. By the end, you'll know exactly how to handle side effects properly in React.

Part 1: The Trap

Imagine you are in a technical interview. The interviewer asks to share their screen and shows a tiny, innocent piece of code:

// ⚠️ BROKEN CODE - Do not use!
function ResizeCounter() {
  const [count, setCount] = useState(0);

  // The problematic logic
  window.addEventListener("resize", () => {
    setCount(count + 1);
  });

  return <h1>Window Resized {count} times</h1>;
}

At first glance, it seems to make sense. Every part you should look for is there: the event, the state update, and a display. Running the code, however, there are some problems that will appear:

Problem 1: The Stuck Counter

The counter will get stuck at 1 and never goes higher.

Problem 2: The Memory Leak

The browser starts to lag or even crash after a few minutes.

But what would the problem be here? Can you understand what is going on? To understand this, we should take a look under the hood and see how React actually thinks.

The Render Cycle with a Groundhog Day Effect

If we think about basic concepts from React, we know that a functional component is just a function that runs from top to bottom every time the state changes.

When count finally changes from 0 to 1 (triggering a state update), the function runs again. This means that the line with the window.addEventListener() will also run again. Which means that every time you resize the window, a new listener will be attached to it, adding a new listener to the window, listening to the same event. Within seconds of this little operation, you will have hundreds of listeners all triggering React updates simultaneously, causing a memory leak.

The Stale Closures and Time Capsules

To understand the reason why the counter stayed at 1 and never updated after that, we should first understand what closures are, and how they become stale.

According to the MDN docs, "A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)." So, in other words, a closure is a function that remembers its outer variables and can access them if needed. Simple, right? Now that we have some understanding of what closures are, let's move on to the staleness.

When the first event listener was created, it captured the value of count when it was 0. Because that specific function instance is never updated, it is trapped in a time capsule. Every time you resize the window, that function will wake up and say: "Count is 0, so now it's time to set it to 0 + 1".

No matter how many times you resize it, the function is forever stuck in the past, forever trying to turn 0 into 1.

Now that we understand where the problem is, let's try and apply the cure for it.

Taming the Side Effect with useEffect

We know that the code is stuck in time, so how do we fix it? We need a way to tell React to only set up the listener once, and make sure it always knows the current count.

And for this we have the useEffect hook.

First, we gotta establish a single source of truth. Instead of letting the listener attach to the window resize event and allowing it to be recreated at every re-render, we wrap it in a useEffect with an empty dependency array (the []). This will ensure that the setup code only runs once, in the Mounting phase of the React Component Lifecycle (or when the component first appears on the screen).

Second, we need to break free from the time capsule. Now we guarantee that our listener is only created once, but it's still suffering from the same problem of never updating from the initial value of count. To fix this without recreating the listener constantly, we can use the functional update pattern in setCount.

Instead of using setCount(count + 1), we use setCount(prevCount => prevCount + 1)

By passing a function, React gives us the most current state as an argument, bypassing the whole "stale closure" problem from before.

Finally, we need to add a cleanup to this solution. The window is a global object, which means our listener will live on forever, even if the user navigates to a different page (since it's still contained inside the window), unless we manually end it.

The best way to do this is by returning a cleanup function inside our useEffect.

In the end, our code will look like this:

useEffect(() => {
  // 1. Define the logic.
  const handleResize = () => {
    setCount(prev => prev + 1);
  };

  // 2. Attach the listener.
  window.addEventListener("resize", handleResize);

  // 3. The Cleanup phase, removing it when the component "dies".
  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []); // 4. The Empty Array means it only runs this once.

Using useRef as a Persistent Bridge

Sometimes, you might have a listener that needs to access multiple state variables or complex logic. Relying solely on functional updates can get messy. Also, useEffect is not always the best answer for everything, and Effects can be considered unnecessary for a plethora of use cases. So let's explore an alternative and implement useRef in a situation where it can shine!

A ref is like a box that can hold a value for the entire lifecycle of the component. Unlike state, changing a ref does not cause a re-render! But more importantly, the reference to the ref box itself always stays the same, even if the content inside it changes.

So let's understand how this hook also solves our problem: First, we can store the count in a ref. Then, we update that ref every time the component renders. And finally, the event listener (which is only created once) looks inside that box whenever it runs. Since the box is always the same box, it always sees the latest value we put there!

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

  // Keep the ref in sync with the state
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const handleResize = () => {
      // We don't use the 'count' variable from the closure.
      // We look at the 'current' value of the ref!
      console.log("The latest count is:", countRef.current);
      setCount(countRef.current + 1);
    };

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []); // Listener is still only added once
}

The most important thing to understand about useRef is its relationship with the React render cycle. When you update a ref by changing its .current property, React doesn't see it as a reason to refresh the UI (no re-renders here!). Unlike useState, which tells React "the data has changed, please draw the screen all over again", useRef is just a storage container. This is why we need to implement a useState alongside it: the state will handle what the user will see (the counter on the screen), while the ref will handle the real counting behind the scenes.

Choosing the Right Tool

So which approach should you use? Here's a quick decision tree:

Use the functional update pattern (setCount(prev => prev + 1)) when:

  1. You only need to update a single piece of state

  2. The logic is simple and self-contained

  3. You want the cleanest, most React-idiomatic solution

Use useRef when:

  1. Your event listener needs access to multiple state variables

  2. You have complex logic that would be awkward with functional updates

  3. You need to read values without triggering updates

Both solutions solve the core problems: they prevent the memory leak by cleaning up listeners, and they escape the stale closure trap by either bypassing closures entirely (functional updates) or creating a reference that always points to fresh data (useRef).

The Bigger Picture

This resize counter is a tiny example, but the lessons apply everywhere: form inputs, scroll handlers, WebSocket messages, timer callbacks—anywhere you mix React state with external events or APIs.

Let’s think about this: React components are time travelers. Every render is a snapshot of a moment in time. When you create closures (like event listeners) during a render, they carry that moment with them forever—unless you give them a way to see the present.

Next time you're in a technical interview and someone shows you innocent-looking code with an event listener, you'll know exactly where to look. And more importantly, you'll know how to fix it.

What other React gotchas have tripped you up? Have you encountered stale closures in the wild? Share your war stories with me!

Keep reading