In React, most event handling belongs on elements, not on window. But there are a few cases where a global listener is the right tool: viewport resizing, scroll tracking, keyboard shortcuts, page visibility, navigation events, and browser online/offline state.
The common pattern is simple. Register the listener in an Effect, clean it up when the component unmounts, and keep the callback small enough to understand later.
The short version
Use window.addEventListener when the event belongs to the browser, not to a single component.
Typical examples include resize for viewport changes, scroll for page-level scroll state, keydown for global shortcuts, visibilitychange for tab focus and backgrounding, popstate and hashchange for navigation state, and online/offline for connection changes.
If the interaction is local to a button, input, or modal, keep the handler on the element or in React props. Global listeners are for global concerns.
The basic React pattern
The safest way to listen on window is inside useEffect.
import { useEffect, useState } from 'react';
export function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <p>Window width: {width}px</p>;
}
That pattern matters for two reasons. First, React can mount and unmount the component many times during development. Second, you do not want duplicate listeners hanging around after a route change or conditional render.
1. Window resize
The most common use of window.addEventListener in React is viewport measurement.
import { useEffect, useState } from 'react';
export function useViewport() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
This is useful for responsive layouts, conditional mobile behavior, and small UI decisions that depend on the viewport rather than CSS alone. If CSS can solve the problem, prefer CSS. If the component needs to make logic decisions, resize is the right browser event.
2. Global scroll tracking
Scroll listeners are useful when you need to know whether the user has scrolled past a threshold, show a back-to-top button, or update a sticky header.
import { useEffect, useState } from 'react';
export function useScrolledPast(limit = 120) {
const [pastLimit, setPastLimit] = useState(false);
useEffect(() => {
function handleScroll() {
setPastLimit(window.scrollY > limit);
}
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [limit]);
return pastLimit;
}
The important option here is passive: true. If you are only reading scroll position, tell the browser you will not call preventDefault(). That keeps scrolling smooth.
3. Keyboard shortcuts
Another common window-level usage is a global keyboard shortcut.
import { useEffect } from 'react';
type ShortcutProps = {
onCommandPalette: () => void;
};
export function GlobalShortcuts({ onCommandPalette }: ShortcutProps) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
onCommandPalette();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCommandPalette]);
return null;
}
This is the right choice for app-wide shortcuts, escape-to-close flows, and any interaction that should work regardless of which element is focused. If the shortcut should only work inside one component, keep it local instead.
4. Visibility and tab focus
visibilitychange, focus, and blur are useful when you need to know whether the user has left the tab or returned to it.
import { useEffect, useState } from 'react';
export function useTabVisibility() {
const [visible, setVisible] = useState(!document.hidden);
useEffect(() => {
function handleVisibilityChange() {
setVisible(!document.hidden);
}
window.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return visible;
}
This is useful for pausing timers, reducing polling, or refreshing stale state when the user comes back to the page. Many data libraries already do this internally, which is another sign that it is a browser-level concern.
5. Navigation events
popstate and hashchange are relevant when your app uses the browser history directly or needs to respond to URL fragment changes.
import { useEffect, useState } from 'react';
export function useCurrentHash() {
const [hash, setHash] = useState(window.location.hash);
useEffect(() => {
function updateHash() {
setHash(window.location.hash);
}
window.addEventListener('hashchange', updateHash);
return () => window.removeEventListener('hashchange', updateHash);
}, []);
return hash;
}
This is especially useful for in-page navigation, docs sites, and legacy routing patterns that still depend on the fragment identifier.
6. Online and offline state
Sometimes the most practical window listeners are the ones that reflect browser connectivity.
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setOnline(true);
}
function handleOffline() {
setOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}
That is useful for chat apps, editors, dashboards, and any UI that needs to show a degraded state when the browser loses connectivity.
A practical comparison
| Window event | Main use in React | What to watch for |
|---|---|---|
| resize | Responsive logic and viewport measurements | Prefer CSS if the logic is purely visual |
| scroll | Back-to-top buttons, sticky state, scroll thresholds | Use passive: true when only reading state |
| keydown | Global shortcuts and escape handlers | Clean up carefully so the shortcut does not leak across screens |
| visibilitychange | Pause work when the tab is hidden | Useful for timers and background refreshes |
| hashchange, popstate | Routing and in-page navigation | Usually unnecessary if your router already handles this |
| online, offline | Connection status indicators | Show useful UI, not just a banner |
A reusable hook is usually better
Once you repeat the pattern more than once, extract it.
import { useEffect } from 'react';
export function useWindowEvent<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions
) {
useEffect(() => {
window.addEventListener(type, handler as EventListener, options);
return () => {
window.removeEventListener(type, handler as EventListener, options);
};
}, [type, handler, options]);
}
That hook is not the only possible shape, but it shows the core idea: keep the add/remove logic together and give the component a clean API.
Why cleanup matters in React
React effects can run more than once in development, especially under Strict Mode. That is not a bug. It is a stress test. If your listener is not cleaned up properly, the extra setup cycle can reveal it.
This is also why anonymous functions are a bad fit for global listeners. If you cannot remove the listener with the same function reference, you will leak behavior across renders.
A few rules of thumb
Bottom line
The main uses of window.addEventListener in React are the ones that describe browser-wide state, not component-local interaction. Resize, scroll, keyboard shortcuts, visibility, routing, and network status are the common cases.
If you put the listener in an Effect, clean it up correctly, and keep the handler focused, window.addEventListener stays simple and predictable. That is the standard you want in React: use global listeners when the browser owns the event, and keep everything else inside the component tree.