- 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.
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.
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
- 01Which values does each long-lived listener read?
- 02Is an empty dependency array hiding a real dependency?
- 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

Written by Sumeet Kumar Paul
Senior Frontend Engineer from India. Frontend Lab is where I reproduce real interface bugs and work through the fix.
Need a hand?
I build frontends like this for a living.
More from the lab
Sumeet Paul· 2 min readState 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.
Sumeet Paul· 2 min readOptimistic 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.
Sumeet Paul· 2 min readDouble 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.