useWindowSize Hook
Read innerWidth and innerHeight in an effect, throttle with rAF, and return zeros on the server so hydration does not invent a desktop width.
- react
- hooks
- layout
- interview
window.innerWidth during render is the SSR cousin of navigator.onLine during render. There is no window on the server, so you throw, or you write typeof window !== 'undefined' ? window.innerWidth : 1024. That 1024 is a made-up desktop. A phone hydrates against desktop markup: mismatch, or a flash of the wrong nav.
useWindowSize reads innerWidth / innerHeight in an effect, returns zeros (or undefined) for the server and the first client paint, and throttles resize with requestAnimationFrame so a drag of the window frame does not commit 40 layouts per second.
If you only need a boolean breakpoint, do not use this hook. Use useMediaQuery and let CSS do the rest. The number is for canvas, virtualizers, and math that a media query cannot express.
- Never read
windowduring render. Server and hydrating client must agree.- Seed with
{ width: 0, height: 0 }(orundefined). Do not seed with 1024, 1280, orscreen.width.- Subscribe to
resize. Measure withinnerWidth/innerHeight. Cleanup the listener and a pending rAF.- rAF-throttle the handler: at most one measure per frame, always the latest size.
width > 768on the zero seed isfalse. That is mobile-first, not a desktop guess. PrefermatchMediafor breakpoints.
innerWidth in an effect, rAF throttle, and the SSR zero trap
import { useEffect, useState } from 'react';
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
let frame = 0;
function measure() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
function onResize() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
}
measure();
window.addEventListener('resize', onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', onResize);
};
}, []);
return size;
}
function CanvasStage() {
const { width, height } = useWindowSize();
if (width === 0 || height === 0) {
return <p>Measuring viewport…</p>;
}
return <canvas width={width} height={height} />;
}
Procedure:
- Server render:
{ width: 0, height: 0 }. Nowindow. - Client first render: the same zeros. Hydration matches.
- After paint,
measure()writes the real size. One extra commit. Layout may shift. That is the cost of being honest. resizefires (often continuously while the user drags).onResizeschedules one rAF. Further events in the same frame cancel and reschedule. The callback reads the latestinnerWidth, not the first event’s.- Unmount:
cancelAnimationFrameplusremoveEventListener. A pending rAF must notsetStateafter unmount.
requestAnimationFrame is the right throttle for visual work. A setTimeout(..., 16) throttle fights refresh rate and still runs in background tabs more eagerly than rAF. The value-hook in useThrottle is for data cadences that are not paint-aligned.
Why zeros, not a desktop guess
| Seed | Server HTML | Phone on first paint | Hydration |
|---|---|---|---|
{ width: 0, height: 0 } |
Empty / mobile-first branch | Same | Matches, then jumps to real size |
{ width: 1024, height: 768 } |
Desktop nav | Desktop nav on a 390px screen | Matches the lie, then jumps, or mismatches if you read window on the client |
window.innerWidth in render |
Throws in Node | Real width | Mismatch vs server |
undefined |
You must not read .width |
Placeholder | Matches if both sides are undefined |
Zeros make width >= 768 false. If your JSX is width >= 768 ? <DesktopNav /> : <MobileNav />, SSR and the first client paint both choose mobile, then the effect may swap to desktop on a laptop. That swap is a visible shift. It is still better than shipping desktop HTML to a phone.
Better: do not branch chrome on this number. Put the nav in CSS (media queries) or in useMediaQuery('(min-width: 48rem)'). Save useWindowSize for the canvas width.
undefined is a cleaner “not measured yet” than 0, because 0 is a real (if degenerate) viewport. If you seed undefined, do not destructure and do math until the effect has run.
What to measure
innerWidth / innerHeight are the viewport, including the area under a classic scrollbar. document.documentElement.clientWidth excludes the vertical scrollbar. For a full-bleed canvas, innerWidth is the usual choice. For “how wide is the content box,” measure an element with ResizeObserver instead of the window.
Mobile URL bars shrink the visual viewport without always giving you the innerHeight you expect. window.visualViewport has its own resize and scroll events. Mention it if the prompt is a sticky footer on iOS. Do not use window.screen.width: that is the screen, not the browser chrome, and it does not update when the user resizes a desktop window.
Do not parse navigator.userAgent to guess mobile. That is UA sniffing.
useSyncExternalStore and object snapshots
A cached snapshot matters if you switch to useSyncExternalStore. getSnapshot must return the same object when the width and height have not changed. A fresh { width, height } every call looks like a mutation and re-renders in a loop.
let snapshot = { width: 0, height: 0 };
function getSnapshot() {
const width = window.innerWidth;
const height = window.innerHeight;
if (snapshot.width !== width || snapshot.height !== height) {
snapshot = { width, height };
}
return snapshot;
}
The useState version does not have that loop: you only call setSize from measure, and React bails out when the next object is… still a new object. setSize({ width, height }) allocates every time, so bail-out does not happen even if both numbers match. Compare before setting:
setSize((prev) =>
prev.width === width && prev.height === height ? prev : { width, height },
);
That is worth writing if resize can fire with the same size (orientation APIs, some mobile chrome). rAF already drops extra frames. The identity check drops extra commits.
Strict Mode double-invokes the effect in development: measure, cleanup, measure. You get two reads, not two listeners, if cleanup removes the first.
Footguns
Reading window in useState lazy init. Lazy init runs on the server for SSR components. Same throw, same mismatch if you feature-detect and read on the client only.
Throttling with debounce. Waiting for quiet means the layout lags behind a drag, then snaps. rAF or a trailing throttle. Not debounce.
Using this hook for prefers-reduced-motion or color scheme. Those are media queries. innerWidth will not tell you.
One listener per component instance. Ten useWindowSize() calls are ten resize listeners. Lift the size once (context, or a tiny store) if a whole tree needs it.
Conditional hook. if (isBrowser) useWindowSize() breaks Rules of Hooks. Always call it. The zero seed is the browser check.
Interview out-loud answer
I never read
innerWidthduring render. Server and the hydrating client both see{ width: 0, height: 0 }, orundefined, so HTML matches. After paint an effect measures and subscribes toresize. I rAF-throttle the handler so a frame gets at most onesetState, and I cancel the frame plus the listener on cleanup. I would not invent a desktop width to dodge zeros. If I only need a breakpoint, I usematchMediaviauseMediaQuery, not this number.setSizeshould reuse the previous object when both numbers are unchanged so I do not churn commits.
Further reading
- MDN:
Window.innerWidth - MDN:
Window.innerHeight - MDN:
Window: resizeevent - MDN:
requestAnimationFrame - react.dev: Synchronizing with Effects
Related on this site
- useMediaQuery Hook: booleans from
matchMedia, not magic widths - Hydration Mismatches: why a guessed 1024 breaks attach
- Throttle Implementation: cadence vs debounce
- requestAnimationFrame: paint-aligned scheduling
- useThrottle Hook: trailing value when rAF is the wrong unit
- ResizeObserver: measure an element, not the window
- client, offset, and scroll Dimensions: which box you actually measured
- Feature Detection vs UA Sniffing: do not guess “mobile”
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.