Event listeners added in useEffect without proper dependency handling can trap state values in stale closures, preventing component updates from being recognized and causing UI updates to fail.
When you attach an event listener (like window.addEventListener or element.addEventListener) in React without proper dependency management, the event handler captures a stale closure of your component's state. This means the handler only has access to state values from when useEffect first ran, not the current values. The event listener itself doesn't prevent re-renders—React still re-renders on state changes—but the listener's frozen state can mask real problems and make updates appear ineffective. If you re-render but the listener still uses old data, parts of your app may seem unresponsive.
If your event handler needs access to state, include that state in the dependency array. This ensures the listener is recreated whenever the state changes.
// WRONG - handler always sees count = 0
useEffect(() => {
window.addEventListener("click", () => {
console.log(count); // Always logs 0
});
}, []); // Empty dependency array
// RIGHT - listener updates when count changes
useEffect(() => {
const handleClick = () => {
console.log(count); // Logs current value
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, [count]); // Include count in dependenciesWhen you include dependencies, the old listener is cleaned up and a new one is attached with fresh state values.
If re-creating the listener on every state change causes performance issues, use a useRef to store the current state value. The listener never changes, but always reads fresh data from the ref.
import { useRef } from "react";
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const handleClick = () => {
console.log(countRef.current); // Always current value
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, []); // Listener never recreates
}The ref acts as a bridge: it's updated in one effect, read in the handler, with no dependency on state.
Instead of reading state directly in the listener, use the functional form of setState, which React always provides the latest value to.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const handleClick = () => {
// setCount receives latest state as argument
setCount(prevCount => {
console.log(prevCount); // Always current
return prevCount + 1;
});
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, []); // No dependencies needed
}This works when you only need the current state to compute a new value, not to read arbitrary data.
Always return a cleanup function from useEffect that removes the listener. Without it, each effect run adds a new listener without removing the old one.
useEffect(() => {
const handleScroll = () => {
console.log("scrolling");
};
window.addEventListener("scroll", handleScroll);
// Cleanup function runs before effect re-runs and on unmount
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);The cleanup function prevents memory leaks and duplicate event handlers.
React 19 introduces useEffectEvent, which creates a non-reactive function that always reads the latest state without changing identity.
import { useEffect, useRef } from "react";
// Pseudo-code for useEffectEvent pattern (experimental in React 19)
function Counter() {
const [count, setCount] = useState(0);
// In React 19: useEffectEvent lets you access latest state
// without creating effect dependencies
useEffect(() => {
const handleClick = () => {
// Always accesses current count
console.log(count);
};
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, [count]);
}This removes the closure problem entirely by ensuring handlers always see fresh state.
Stale Closures Explained: When JavaScript creates a closure (like an event handler function), it captures the variables in its scope. In React, if that closure is defined in useEffect with an empty dependency array, it captures state values from the initial render. Future re-renders don't update the closure—it still references the old values. This is different from inline event handlers on JSX elements, which are re-created every render and always see current state. Performance Consideration: Including every state variable in the dependency array causes the listener to re-attach on every change, which can be expensive for frequent updates. That's why useRef and functional updates are valuable—they let you read current values without recreating the listener. Event Listener Memory Leaks: Not removing listeners in the cleanup function causes severe memory leaks, especially in single-page apps where components mount and unmount repeatedly. Each mount adds listeners without removing old ones, eventually degrading performance. Custom Hooks: The patterns above work well in a custom hook like useEventListener, which handles dependencies and cleanup automatically—reducing boilerplate and bugs.
Prop spreading could cause security issues
Prop spreading could cause security issues
Error: error:0308010C:digital envelope routines::unsupported
Error: error:0308010C:digital envelope routines::unsupported
React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render.
React Hook useEffect placed inside a condition
Hook can only be called inside the body of a function component
Hook can only be called inside the body of a function component
Rollup failed to resolve import during build
How to fix "Rollup failed to resolve import" in React