Build a hook that subscribes to a DOM event and unsubscribes automatically. Wiring up addEventListener by hand inside a component is repetitive and error-prone: you have to attach the listener, remember to remove it on unmount, and keep the handler from going stale across renders. useEventListener(eventName, handler, element) packages all of that — it attaches handler for eventName on the target (defaulting to window), always calls the latest handler, and removes the listener when the component unmounts or the target changes.
function useEventListener(
eventName: string,
handler: (event: Event) => void,
element?: EventTarget, // defaults to window
): void;
The hook returns nothing. It is the foundation other DOM hooks build on — useHover, useClickOutside, useKeyPress, and useWindowSize are all thin layers over this pattern.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
// Re-read the width whenever the window resizes. Cleanup is automatic.
useEventListener('resize', () => setWidth(window.innerWidth));
return <span>{width}px</span>;
}
// Attach to a specific element instead of window:
const ref = useRef(null);
useEventListener('click', handleClick, ref.current);
window. Omitting element subscribes on window; passing one subscribes on that target instead.eventName or element should move the subscription; merely changing the handler should not.You'll subscribe to a DOM event inside an effect, route every fired event through a ref that always holds the latest handler, and unsubscribe in the effect's cleanup.
Listening for a browser event from a component sounds like one line — addEventListener — but three things make it subtle. The listener must be removed when the component goes away, or it leaks and keeps firing into a component that no longer exists. The handler is usually a fresh closure on every render (especially inline arrow functions), so a listener attached once can end up calling a stale version that closes over old state. And you only want to actually re-subscribe when the target of the subscription changes — the event name or element — not every time the handler's identity churns. A good useEventListener solves all three at once.
A subscription has a lifecycle that must line up with the component's: subscribe on mount, fire while alive, unsubscribe on unmount. An effect models exactly that — the effect body subscribes, and its returned cleanup unsubscribes.
The freshness problem is solved the same way useInterval solves it: keep a ref pointed at the latest handler, and have the actual listener call savedHandler.current(event). That way the subscription itself can depend only on [eventName, element] and stay put across renders, while still dispatching to the current handler.
The direct version attaches the handler on mount:
const { useEffect } = require('react');
function useEventListener(eventName, handler, element) {
useEffect(() => {
const target = element ?? window;
target.addEventListener(eventName, handler);
}, []); // attach once
}
Two bugs. There's no cleanup, so the listener is never removed — after the component unmounts it keeps firing, and every time the effect re-runs another listener stacks on top. And the empty dependency array captures the first handler; if the component re-renders with a new one, events still call the stale original. Adding [handler] to the deps and a cleanup would fix staleness and leaks, but it introduces churn: with an inline handler (new every render), you'd tear down and rebuild the subscription on every single render.
const { useEffect, useRef } = require('react');
function useEventListener(eventName, handler, element) {
// Hold the latest handler in a ref so the subscription never goes stale.
const savedHandler = useRef(handler);
// Keep the ref current. Cheap, and crucially does NOT touch the subscription.
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
// Own the subscription. Re-runs only when the target (eventName/element)
// changes. The listener reads savedHandler.current at event time, so it
// always calls the latest handler without re-subscribing on handler changes.
useEffect(() => {
const target = element ?? window;
if (!target || !target.addEventListener) return;
const listener = (event) => savedHandler.current(event);
target.addEventListener(eventName, listener);
return () => target.removeEventListener(eventName, listener);
}, [eventName, element]);
}
module.exports = { useEventListener };
The shape mirrors useInterval: one effect keeps the handler fresh, a second owns the resource. The second effect attaches a small wrapper listener (not handler directly) that forwards to savedHandler.current, and returns a cleanup that removes that same wrapper — so unmount and target changes always unsubscribe correctly. Depending on [eventName, element] means a changing handler never disturbs the subscription, while a changing target moves it.
Take useEventListener('resize', onResizeA), then a re-render to useEventListener('resize', onResizeB):
savedHandler.current is set to onResizeA. The subscription effect runs: target defaults to window, and it calls window.addEventListener('resize', listener) where listener forwards to savedHandler.current.listener runs and calls savedHandler.current(event) — onResizeA — passing the event.onResizeB. The first effect runs (its dep handler changed) and sets savedHandler.current = onResizeB. The subscription effect does not re-run, because eventName and element are unchanged — the same listener stays attached.savedHandler.current(event) — now onResizeB. The latest handler ran, with no re-subscribe.window.removeEventListener('resize', listener). No more events reach the (now gone) component.return () => removeEventListener(...), listeners leak: they keep firing after unmount and stack up every time the effect re-runs. Fix: always remove the listener in the effect's cleanup.addEventListener(name, handler) with [] freezes the first handler, so updates that depend on new state never run. Fix: store the handler in a ref and subscribe a wrapper that reads savedHandler.current.handler in the subscription effect's deps. This re-subscribes on every handler change — and with an inline handler that's every render, thrashing the listener. Fix: the subscription effect depends on [eventName, element] only.addEventListener(name, e => saved.current(e)) and removeEventListener(name, e => saved.current(e)) use two different function objects, so the removal silently does nothing. Fix: name the wrapper once (const listener = ...) and pass that same reference to both add and remove.if (!target || !target.addEventListener) return; lets the hook no-op when window is undefined (server render) or the ref isn't attached yet.options argument ({ passive, capture, once }) through to addEventListener/removeEventListener covers cases like passive scroll listeners.eventName against WindowEventMap/HTMLElementEventMap gives the handler a precisely typed event instead of the base Event.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that subscribes to a DOM event and unsubscribes automatically. Wiring up addEventListener by hand inside a component is repetitive and error-prone: you have to attach the listener, remember to remove it on unmount, and keep the handler from going stale across renders. useEventListener(eventName, handler, element) packages all of that — it attaches handler for eventName on the target (defaulting to window), always calls the latest handler, and removes the listener when the component unmounts or the target changes.
function useEventListener(
eventName: string,
handler: (event: Event) => void,
element?: EventTarget, // defaults to window
): void;
The hook returns nothing. It is the foundation other DOM hooks build on — useHover, useClickOutside, useKeyPress, and useWindowSize are all thin layers over this pattern.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
// Re-read the width whenever the window resizes. Cleanup is automatic.
useEventListener('resize', () => setWidth(window.innerWidth));
return <span>{width}px</span>;
}
// Attach to a specific element instead of window:
const ref = useRef(null);
useEventListener('click', handleClick, ref.current);
window. Omitting element subscribes on window; passing one subscribes on that target instead.eventName or element should move the subscription; merely changing the handler should not.You'll subscribe to a DOM event inside an effect, route every fired event through a ref that always holds the latest handler, and unsubscribe in the effect's cleanup.
Listening for a browser event from a component sounds like one line — addEventListener — but three things make it subtle. The listener must be removed when the component goes away, or it leaks and keeps firing into a component that no longer exists. The handler is usually a fresh closure on every render (especially inline arrow functions), so a listener attached once can end up calling a stale version that closes over old state. And you only want to actually re-subscribe when the target of the subscription changes — the event name or element — not every time the handler's identity churns. A good useEventListener solves all three at once.
A subscription has a lifecycle that must line up with the component's: subscribe on mount, fire while alive, unsubscribe on unmount. An effect models exactly that — the effect body subscribes, and its returned cleanup unsubscribes.
The freshness problem is solved the same way useInterval solves it: keep a ref pointed at the latest handler, and have the actual listener call savedHandler.current(event). That way the subscription itself can depend only on [eventName, element] and stay put across renders, while still dispatching to the current handler.
The direct version attaches the handler on mount:
const { useEffect } = require('react');
function useEventListener(eventName, handler, element) {
useEffect(() => {
const target = element ?? window;
target.addEventListener(eventName, handler);
}, []); // attach once
}
Two bugs. There's no cleanup, so the listener is never removed — after the component unmounts it keeps firing, and every time the effect re-runs another listener stacks on top. And the empty dependency array captures the first handler; if the component re-renders with a new one, events still call the stale original. Adding [handler] to the deps and a cleanup would fix staleness and leaks, but it introduces churn: with an inline handler (new every render), you'd tear down and rebuild the subscription on every single render.
const { useEffect, useRef } = require('react');
function useEventListener(eventName, handler, element) {
// Hold the latest handler in a ref so the subscription never goes stale.
const savedHandler = useRef(handler);
// Keep the ref current. Cheap, and crucially does NOT touch the subscription.
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
// Own the subscription. Re-runs only when the target (eventName/element)
// changes. The listener reads savedHandler.current at event time, so it
// always calls the latest handler without re-subscribing on handler changes.
useEffect(() => {
const target = element ?? window;
if (!target || !target.addEventListener) return;
const listener = (event) => savedHandler.current(event);
target.addEventListener(eventName, listener);
return () => target.removeEventListener(eventName, listener);
}, [eventName, element]);
}
module.exports = { useEventListener };
The shape mirrors useInterval: one effect keeps the handler fresh, a second owns the resource. The second effect attaches a small wrapper listener (not handler directly) that forwards to savedHandler.current, and returns a cleanup that removes that same wrapper — so unmount and target changes always unsubscribe correctly. Depending on [eventName, element] means a changing handler never disturbs the subscription, while a changing target moves it.
Take useEventListener('resize', onResizeA), then a re-render to useEventListener('resize', onResizeB):
savedHandler.current is set to onResizeA. The subscription effect runs: target defaults to window, and it calls window.addEventListener('resize', listener) where listener forwards to savedHandler.current.listener runs and calls savedHandler.current(event) — onResizeA — passing the event.onResizeB. The first effect runs (its dep handler changed) and sets savedHandler.current = onResizeB. The subscription effect does not re-run, because eventName and element are unchanged — the same listener stays attached.savedHandler.current(event) — now onResizeB. The latest handler ran, with no re-subscribe.window.removeEventListener('resize', listener). No more events reach the (now gone) component.return () => removeEventListener(...), listeners leak: they keep firing after unmount and stack up every time the effect re-runs. Fix: always remove the listener in the effect's cleanup.addEventListener(name, handler) with [] freezes the first handler, so updates that depend on new state never run. Fix: store the handler in a ref and subscribe a wrapper that reads savedHandler.current.handler in the subscription effect's deps. This re-subscribes on every handler change — and with an inline handler that's every render, thrashing the listener. Fix: the subscription effect depends on [eventName, element] only.addEventListener(name, e => saved.current(e)) and removeEventListener(name, e => saved.current(e)) use two different function objects, so the removal silently does nothing. Fix: name the wrapper once (const listener = ...) and pass that same reference to both add and remove.if (!target || !target.addEventListener) return; lets the hook no-op when window is undefined (server render) or the ref isn't attached yet.options argument ({ passive, capture, once }) through to addEventListener/removeEventListener covers cases like passive scroll listeners.eventName against WindowEventMap/HTMLElementEventMap gives the handler a precisely typed event instead of the base Event.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.