Build a hook that runs a callback whenever the user clicks anywhere on the page. A surprising number of features need this: closing a dropdown or popover on an outside click, dismissing a tooltip, tracking the last interaction time, or hiding a custom context menu. useClickAnywhere(handler) attaches a single click listener on window — which sees every click in the document because clicks bubble up to it — calls handler(event) each time, and removes the listener when the component unmounts.
function useClickAnywhere(
handler: (event: MouseEvent) => void,
): void;
The hook returns nothing. It is a pre-bound cousin of useEventListener: the event is always click and the target is always window, so the only argument is the callback.
function ClickCounter() {
const [clicks, setClicks] = useState(0);
// Count every click anywhere on the page. Cleanup is automatic.
useClickAnywhere(() => setClicks((c) => c + 1));
return <span>Clicks: {clicks}</span>;
}
// Close an open menu when the user clicks anywhere:
useClickAnywhere((event) => {
if (isOpen) setOpen(false);
});
window, always click. Unlike a general listener hook, the target and event are fixed. You only pass the callback.handler receives the MouseEvent, so callers can read event.target, coordinates, or modifier keys.You'll attach one click listener on window inside an effect, route every click through a ref that always holds the latest callback, and remove the listener in the effect's cleanup.
Lots of UI behaves differently when you click "outside" of it: an open menu should close, a tooltip should vanish, a custom context menu should disappear. The common ingredient is a single listener on window that hears every click on the page — because clicks bubble all the way up to window — and runs your callback. The two things that make this subtle are the same two that make any subscription subtle: the listener must be removed when the component goes away, and it must always call the current callback even though that callback is usually a fresh function on every render.
A listener has a lifecycle that must line up with the component's: subscribe on mount, fire while the component is alive, unsubscribe on unmount. A React effect models exactly that — the effect body subscribes, and its returned cleanup unsubscribes.
The freshness problem is solved with a ref: keep a ref pointed at the latest handler, and have the actual listener call savedHandler.current(event). That way the subscription can attach once and stay put, while still dispatching to whatever the current callback is.
The direct version just attaches the handler on mount:
const { useEffect } = require('react');
function useClickAnywhere(handler) {
useEffect(() => {
window.addEventListener('click', handler);
}, []); // attach once
}
Two bugs. There's no cleanup, so the listener is never removed — after the component unmounts it keeps firing into a component that's gone. And the empty dependency array captures the first handler; if the component re-renders with a new callback (an inline arrow function is a brand-new function every render), clicks still run the stale original. You can't fix staleness by adding [handler] to these deps either: with an inline handler that's a new function every render, you'd tear down and re-attach the listener on every single render.
const { useEffect, useRef } = require('react');
function useClickAnywhere(handler) {
// 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. Attaches once (deps []). The listener reads
// savedHandler.current at click time, so it always calls the latest handler
// without re-subscribing, and the cleanup removes that same listener.
useEffect(() => {
const listener = (event) => savedHandler.current(event);
window.addEventListener('click', listener);
return () => window.removeEventListener('click', listener);
}, []);
}
module.exports = { useClickAnywhere };
The key shift is the indirection through the ref. The subscription 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 always unsubscribes correctly. Because the wrapper reads the ref fresh on each click, a changing handler never disturbs the subscription, so its deps can stay [] and it attaches exactly once.
Take useClickAnywhere(onClickA), then a re-render to useClickAnywhere(onClickB):
savedHandler.current is set to onClickA. The subscription effect runs once: window.addEventListener('click', listener), where listener forwards to savedHandler.current.window, the wrapper listener runs and calls savedHandler.current(event) — onClickA — passing the MouseEvent.onClickB. The first effect runs (its dep handler changed) and sets savedHandler.current = onClickB. The subscription effect does not re-run, because its deps are [] — the same listener stays attached.savedHandler.current(event) — now onClickB. The latest callback ran, with no re-subscribe.window.removeEventListener('click', listener). No more clicks reach the (now gone) component.return () => window.removeEventListener('click', listener), the listener leaks: it keeps firing after the component unmounts, and a click then runs a callback that may touch state on a component that no longer exists. Fix: always remove the listener in the effect's cleanup.addEventListener('click', handler) with [] freezes the first handler, so a re-render with a new callback never takes effect. Fix: store the handler in a ref and subscribe a wrapper that reads savedHandler.current.handler in the subscription effect's deps. That re-subscribes on every handler change — and with an inline arrow handler that's every render, thrashing the listener. Fix: the subscription effect depends on []; the ref-update effect is the only one that depends on [handler].addEventListener('click', e => savedHandler.current(e)) and removeEventListener('click', e => savedHandler.current(e)) are 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.{ capture: true } to both add and remove makes the handler run during the capture phase, before inner handlers can stopPropagation — handy for "close everything" behaviour that must not be swallowed.pointerdown over click. Switching to pointerdown (or mousedown) fires the handler the instant the press begins rather than after release, which feels snappier for dismissing menus and is the basis of useClickOutside.useClickOutside on top. Add a ref parameter and only fire when !ref.current.contains(event.target) — the same window subscription, narrowed to clicks that land outside a specific element.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that runs a callback whenever the user clicks anywhere on the page. A surprising number of features need this: closing a dropdown or popover on an outside click, dismissing a tooltip, tracking the last interaction time, or hiding a custom context menu. useClickAnywhere(handler) attaches a single click listener on window — which sees every click in the document because clicks bubble up to it — calls handler(event) each time, and removes the listener when the component unmounts.
function useClickAnywhere(
handler: (event: MouseEvent) => void,
): void;
The hook returns nothing. It is a pre-bound cousin of useEventListener: the event is always click and the target is always window, so the only argument is the callback.
function ClickCounter() {
const [clicks, setClicks] = useState(0);
// Count every click anywhere on the page. Cleanup is automatic.
useClickAnywhere(() => setClicks((c) => c + 1));
return <span>Clicks: {clicks}</span>;
}
// Close an open menu when the user clicks anywhere:
useClickAnywhere((event) => {
if (isOpen) setOpen(false);
});
window, always click. Unlike a general listener hook, the target and event are fixed. You only pass the callback.handler receives the MouseEvent, so callers can read event.target, coordinates, or modifier keys.You'll attach one click listener on window inside an effect, route every click through a ref that always holds the latest callback, and remove the listener in the effect's cleanup.
Lots of UI behaves differently when you click "outside" of it: an open menu should close, a tooltip should vanish, a custom context menu should disappear. The common ingredient is a single listener on window that hears every click on the page — because clicks bubble all the way up to window — and runs your callback. The two things that make this subtle are the same two that make any subscription subtle: the listener must be removed when the component goes away, and it must always call the current callback even though that callback is usually a fresh function on every render.
A listener has a lifecycle that must line up with the component's: subscribe on mount, fire while the component is alive, unsubscribe on unmount. A React effect models exactly that — the effect body subscribes, and its returned cleanup unsubscribes.
The freshness problem is solved with a ref: keep a ref pointed at the latest handler, and have the actual listener call savedHandler.current(event). That way the subscription can attach once and stay put, while still dispatching to whatever the current callback is.
The direct version just attaches the handler on mount:
const { useEffect } = require('react');
function useClickAnywhere(handler) {
useEffect(() => {
window.addEventListener('click', handler);
}, []); // attach once
}
Two bugs. There's no cleanup, so the listener is never removed — after the component unmounts it keeps firing into a component that's gone. And the empty dependency array captures the first handler; if the component re-renders with a new callback (an inline arrow function is a brand-new function every render), clicks still run the stale original. You can't fix staleness by adding [handler] to these deps either: with an inline handler that's a new function every render, you'd tear down and re-attach the listener on every single render.
const { useEffect, useRef } = require('react');
function useClickAnywhere(handler) {
// 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. Attaches once (deps []). The listener reads
// savedHandler.current at click time, so it always calls the latest handler
// without re-subscribing, and the cleanup removes that same listener.
useEffect(() => {
const listener = (event) => savedHandler.current(event);
window.addEventListener('click', listener);
return () => window.removeEventListener('click', listener);
}, []);
}
module.exports = { useClickAnywhere };
The key shift is the indirection through the ref. The subscription 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 always unsubscribes correctly. Because the wrapper reads the ref fresh on each click, a changing handler never disturbs the subscription, so its deps can stay [] and it attaches exactly once.
Take useClickAnywhere(onClickA), then a re-render to useClickAnywhere(onClickB):
savedHandler.current is set to onClickA. The subscription effect runs once: window.addEventListener('click', listener), where listener forwards to savedHandler.current.window, the wrapper listener runs and calls savedHandler.current(event) — onClickA — passing the MouseEvent.onClickB. The first effect runs (its dep handler changed) and sets savedHandler.current = onClickB. The subscription effect does not re-run, because its deps are [] — the same listener stays attached.savedHandler.current(event) — now onClickB. The latest callback ran, with no re-subscribe.window.removeEventListener('click', listener). No more clicks reach the (now gone) component.return () => window.removeEventListener('click', listener), the listener leaks: it keeps firing after the component unmounts, and a click then runs a callback that may touch state on a component that no longer exists. Fix: always remove the listener in the effect's cleanup.addEventListener('click', handler) with [] freezes the first handler, so a re-render with a new callback never takes effect. Fix: store the handler in a ref and subscribe a wrapper that reads savedHandler.current.handler in the subscription effect's deps. That re-subscribes on every handler change — and with an inline arrow handler that's every render, thrashing the listener. Fix: the subscription effect depends on []; the ref-update effect is the only one that depends on [handler].addEventListener('click', e => savedHandler.current(e)) and removeEventListener('click', e => savedHandler.current(e)) are 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.{ capture: true } to both add and remove makes the handler run during the capture phase, before inner handlers can stopPropagation — handy for "close everything" behaviour that must not be swallowed.pointerdown over click. Switching to pointerdown (or mousedown) fires the handler the instant the press begins rather than after release, which feels snappier for dismissing menus and is the basis of useClickOutside.useClickOutside on top. Add a ref parameter and only fire when !ref.current.contains(event.target) — the same window subscription, narrowed to clicks that land outside a specific element.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.