Skip to content

Blog / Stale Closure

Stale closure in click outside listener

Sumeet Kumar Paul2 min read
The symptom
A click-outside handler acts on a value that is no longer true.
The cause
A listener attached once keeps the state from the render that created it.
The fix
Keep the listener stable and read the latest value through a ref.

Problem

Event listeners defined inside effects capture state from the render during which they were created. If the listener is attached once and depends on state that later changes, it may continue operating on outdated values.

  • Dropdown open state toggled by button.
  • Document click listener attached once on mount.
  • Listener captures initial 'open' value.
  • UI state and listener logic fall out of sync.
page.tsx
srcapppracticestale-closure-in-click-outside-listenerpage.tsx
1useEffect(() => {
2  function handleClick(event: MouseEvent) {
3    console.log("[BROKEN] listener sees open =", open);
4
5    const target = event.target as Node;
6    const isOutside =
7      containerRef.current &&
8      !containerRef.current.contains(target);
9
10    if (open && isOutside) {
11      console.log("[BROKEN] closing dropdown");
12      setOpen(false);
13    }
14  }
15
16  document.addEventListener("click", handleClick);
17
18  return () => {
19    document.removeEventListener("click", handleClick);
20  };
21}, []);

Because the effect runs only once, the event listener permanently captures the initial value of 'open'. The dropdown state changes visually, but the listener continues evaluating stale state.

Solution

Keep the event listener stable, but separate state freshness from subscription lifecycle. Store the latest state in a mutable ref and read from it inside the listener.

page.tsx
srcapppracticestale-closure-in-click-outside-listenerpage.tsx
1const openRef = useRef(open);
2const containerRef = useRef<HTMLDivElement | null>(null);
3
4useEffect(() => {
5  openRef.current = open;
6}, [open]);
7
8useEffect(() => {
9  function handleClick(event: MouseEvent) {
10    const target = event.target as Node;
11    const isOutside =
12      containerRef.current &&
13      !containerRef.current.contains(target);
14
15    console.log("[FIXED] click detected | openRef:", openRef.current, "| outside:", isOutside);
16
17    if (openRef.current && isOutside) {
18      console.log("[FIXED] closing dropdown");
19      setOpen(false);
20    }
21  }
22
23  document.addEventListener("click", handleClick);
24
25  return () => {
26    document.removeEventListener("click", handleClick);
27  };
28}, []);

The listener remains attached only once, preventing unnecessary re-subscriptions. State freshness is handled independently through a ref, ensuring the dropdown logic always evaluates the latest value without closure drift.

Check your own code

  1. 01Which values does each long-lived listener read?
  2. 02Is an empty dependency array hiding a real dependency?
  3. 03Would a ref or a functional update give the latest value more safely?
  • stale closure
  • react event listener bug
  • click outside issue
  • frontend state safety
  • ui correctness
View the source on GitHub ↗

Written by Sumeet Kumar Paul

Senior Frontend Engineer from India. Frontend Lab is where I reproduce real interface bugs and work through the fix.

Share article:

Need a hand?

I build frontends like this for a living.

Get in touch

More from the lab