GitHub

Copyright © 2026 Sumit Paul

Design by Sumit Paul•LinkedIn

LinkedIn

Stale closure in click outside listener

Avatar

Sumeet Kumar Paul

Frontend Engineer

2 min readSource code
Feb 13, 2026

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-click-outsidepage.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-click-outsidepage.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.

Closures capture values at creation time. When subscriptions remain stable but state changes over time, logic may silently operate on outdated data. Separating subscription stability from state freshness prevents subtle UI inconsistencies and aligns event handling with real user behavior.

Keywords

stale closure, react event listener bug, click outside issue, frontend state safety, ui correctness

More Cases

2 min readFeb 20, 2026

State update after route transition

A component starts asynchronous work and then a route transition occurs before the work completes. When the async task resolves, it updates state that no longer matches the active route.

Avatar

Sumeet Kumar Paul

2 min readFeb 18, 2026

Optimistic UI rollback failure

An optimistic UI update immediately reflects a user action before the server confirms success. If the request fails or multiple toggles occur quickly, the UI can drift away from actual server state.

Avatar

Sumeet Kumar Paul

2 min readFeb 16, 2026

Double submit caused by UI state lag

A submit button is disabled only after state updates to 'loading'. Under rapid user interaction, multiple clicks can occur before the UI visually reflects the disabled state.

Avatar

Sumeet Kumar Paul

2 min readFeb 10, 2026

State update after unmount during navigation

When a component starts asynchronous work and the user navigates away before it resolves, the async callback may still attempt to update state. At that point, the component no longer exists in the UI tree.

Avatar

Sumeet Kumar Paul