React useEventListener Hook
Manually attaching DOM event listeners inside useEffect is repetitive and easy to misuse: forgotten cleanups leak handlers, and stale callbacks see old state. The useEventListener hook centralises the pattern so consumers just declare which event they care about. This snippet covers the basic window listener, a target-aware variant that supports any element ref, and a typed signature that picks the right event payload.
365 views
1
function useEventListener(eventName, handler) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const listener = (e) => handlerRef.current(e);
window.addEventListener(eventName, listener);
return () => window.removeEventListener(eventName, listener);
}, [eventName]);
}
function useRef(v) { return { current: v }; }
function useEffect(fn) { fn(); }
useEventListener('resize', () => console.log('resized'));
console.log('window resize listener attached');The hook stores the handler in a ref so the listener attached to the DOM stays referentially stable across renders, while still calling the latest handler. This avoids the subtle bug where a handler closure captures stale props. The typeof window guard makes the hook safe in SSR. Reach for this any time you need to listen on window (resize, scroll, online / offline, beforeunload) and want to keep your component bodies clean.
function useEventListenerOn(eventName, handler, target) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
const node = target && 'current' in target ? target.current : target;
if (!node || !node.addEventListener) return undefined;
const listener = (e) => handlerRef.current(e);
node.addEventListener(eventName, listener);
return () => node.removeEventListener(eventName, listener);
}, [eventName, target]);
}
const dummyTarget = { current: { addEventListener: () => {}, removeEventListener: () => {} } };
useEventListenerOn('click', () => console.log('clicked'), dummyTarget);
console.log('target listener attached');Most listeners are not on window but on a specific element: a popover wrapper for outside-click handling, an input ref for the native change event, a scroll container. Accepting either a ref-shaped object or a raw element keeps the API forgiving. The early-return when there is no node makes the hook safe during the first render before the ref has been assigned. Notice that we do NOT depend on handler for the listener effect, so toggling props does not detach and reattach the listener every render.
function useEventListeners(eventNames, handler, target) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }, [handler]);
useEffect(() => {
const node = target && 'current' in target ? target.current : target;
if (!node || !node.addEventListener) return undefined;
const listener = (e) => handlerRef.current(e);
for (const name of eventNames) node.addEventListener(name, listener);
return () => {
for (const name of eventNames) node.removeEventListener(name, listener);
};
}, [eventNames.join(','), target]);
}
const dummy2 = { current: { addEventListener: () => {}, removeEventListener: () => {} } };
useEventListeners(['mousedown', 'touchstart'], () => console.log('press'), dummy2);
console.log('multi-listener attached');Some interactions need to listen for several events at once: a popover dismisser wants both mousedown and touchstart, a focus-within helper wants both focusin and focusout. Accepting an array iterates the same setup once per name and pairs every add with a matching remove. Joining the names for the dependency array gives a stable string identity so the effect does not re-run when the parent passes a new array literal each render. For full type safety wrap this in a TypeScript overload that maps each event name to its payload type.
