ESC

Type to search the knowledge base.

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.

beginner6 min read
  • 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 window during render. Server and hydrating client must agree.
  • Seed with { width: 0, height: 0 } (or undefined). Do not seed with 1024, 1280, or screen.width.
  • Subscribe to resize. Measure with innerWidth / innerHeight. Cleanup the listener and a pending rAF.
  • rAF-throttle the handler: at most one measure per frame, always the latest size.
  • width > 768 on the zero seed is false. That is mobile-first, not a desktop guess. Prefer matchMedia for 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:

  1. Server render: { width: 0, height: 0 }. No window.
  2. Client first render: the same zeros. Hydration matches.
  3. After paint, measure() writes the real size. One extra commit. Layout may shift. That is the cost of being honest.
  4. resize fires (often continuously while the user drags). onResize schedules one rAF. Further events in the same frame cancel and reschedule. The callback reads the latest innerWidth, not the first event’s.
  5. Unmount: cancelAnimationFrame plus removeEventListener. A pending rAF must not setState after 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 innerWidth during render. Server and the hydrating client both see { width: 0, height: 0 }, or undefined, so HTML matches. After paint an effect measures and subscribes to resize. I rAF-throttle the handler so a frame gets at most one setState, 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 use matchMedia via useMediaQuery, not this number. setSize should reuse the previous object when both numbers are unchanged so I do not churn commits.

Further reading

Related guides