React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

window-sizeresizeresponsiveviewportssrcustom-hook

useWindowSize — Track window dimensions reactively with debounced updates

Recipe

import { useState, useEffect, useCallback, useRef } from "react";
 
interface WindowSize {
  width: number;
  height: number;
}
 
interface UseWindowSizeOptions {
  /** Debounce delay in ms. Default: 100 */
  debounceDelay?: number;
  /** Initial size for SSR. Default: { width: 0, height: 0 } */
  initialSize?: WindowSize;
}
 
function useWindowSize(options: UseWindowSizeOptions = {}): WindowSize {
  const {
    debounceDelay = 100,
    initialSize = { width: 0, height: 0 },
  } = options;
 
  const [size, setSize] = useState<WindowSize>(() => {
    if (typeof window === "undefined") return initialSize;
    return {
      width: window.innerWidth,
      height: window.innerHeight,
    };
  });
 
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
  useEffect(() => {
    if (typeof window === "undefined") return;
 
    const handleResize = () => {
      if (timerRef.current) clearTimeout(timerRef.current);
 
      timerRef.current = setTimeout(() => {
        setSize({
          width: window.innerWidth,
          height: window.innerHeight,
        });
        timerRef.current = null;
      }, debounceDelay);
    };
 
    // Set initial size on mount
    setSize({
      width: window.innerWidth,
      height: window.innerHeight,
    });
 
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [debounceDelay]);
 
  return size;
}

When to reach for this: You need window dimensions in JavaScript for responsive layout calculations, canvas sizing, virtualized lists, or conditional rendering that CSS media queries cannot handle.

Working Example

"use client";
 
function ResponsiveLayout() {
  const { width, height } = useWindowSize();
 
  const columns = width >= 1024 ? 3 : width >= 640 ? 2 : 1;
 
  return (
    <div>
      <p>
        Window: {width} x {height}
      </p>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: `repeat(${columns}, 1fr)`,
          gap: 16,
        }}
      >
        {Array.from({ length: 6 }, (_, i) => (
          <div
            key={i}
            style={{
              padding: 24,
              background: "#f5f5f5",
              borderRadius: 8,
              textAlign: "center",
            }}
          >
            Card {i + 1}
          </div>
        ))}
      </div>
    </div>
  );
}
 
function CanvasSizer() {
  const { width } = useWindowSize({ debounceDelay: 200 });
 
  const canvasWidth = Math.min(width - 32, 800);
  const canvasHeight = canvasWidth * 0.5625; // 16:9
 
  return (
    <canvas
      width={canvasWidth}
      height={canvasHeight}
      style={{ border: "1px solid #ccc" }}
    />
  );
}

What this demonstrates:

  • Grid column count adapts based on window width breakpoints
  • Canvas maintains a 16:9 aspect ratio relative to window width
  • Debouncing prevents excessive re-renders during resize drag
  • Custom debounce delay for the canvas component (200 ms)

Deep Dive

How It Works

  • Lazy initializer: useState reads window.innerWidth and window.innerHeight only on the client, falling back to initialSize during SSR.
  • Debounced resize handler: A setTimeout-based debounce collapses rapid resize events into a single state update, preventing jank.
  • Cleanup: The event listener and any pending timer are cleaned up on unmount.
  • Re-sync on mount: The effect immediately sets the current size to handle cases where the window was resized while the component was unmounted.

Parameters & Return Values

OptionTypeDefaultDescription
debounceDelaynumber100Milliseconds to debounce resize events
initialSize{ width, height }{ width: 0, height: 0 }Size returned during SSR
ReturnTypeDescription
widthnumberCurrent window.innerWidth
heightnumberCurrent window.innerHeight

Variations

Without debounce: For immediate updates (e.g., drag-resize previews), set debounceDelay: 0 or remove the setTimeout:

const size = useWindowSize({ debounceDelay: 0 });

With orientation: Detect landscape vs portrait:

function useOrientation() {
  const { width, height } = useWindowSize();
  return width > height ? "landscape" : "portrait";
}

Document size (scroll height): Track the full document height instead of viewport:

// Inside the resize handler:
setSize({
  width: document.documentElement.scrollWidth,
  height: document.documentElement.scrollHeight,
});

TypeScript Notes

  • The WindowSize interface is exported so consumers can type their own state or props.
  • No generics needed; return type is always { width: number; height: number }.
  • The options interface uses optional properties with defaults destructured in the function body.

Gotchas

  • SSR hydration mismatch — Server renders with initialSize (0x0), but the client immediately updates to real dimensions. Fix: This causes a layout shift on first render. For critical layouts, prefer CSS media queries or provide a reasonable initialSize estimate.
  • Too frequent updates — Without debouncing, every pixel of a resize drag triggers a re-render. Fix: The built-in debounce handles this; adjust debounceDelay as needed.
  • Virtual keyboard on mobile — On mobile, opening the keyboard resizes the viewport and triggers the handler. Fix: Use window.visualViewport API if you need to distinguish keyboard from resize.
  • iframe context — Inside an iframe, window.innerWidth reflects the iframe size, not the parent window. Fix: Use parent.window if cross-origin policy allows, or pass size as a prop.

Alternatives

PackageHook NameNotes
usehooks-tsuseWindowSizeNo built-in debounce
@uidotdev/usehooksuseWindowSizeMinimal implementation
ahooksuseSizeTracks any element, not just window
react-useuseWindowSizeIncludes server-side defaults
@react-hook/window-sizeuseWindowSizeThrottled variant available

FAQs

Why does the hook debounce resize events by default?

Without debouncing, every pixel of a resize drag triggers a state update and re-render. The built-in debounce collapses rapid events into a single update, preventing jank and wasted renders.

How do I disable debouncing for immediate updates?

Set debounceDelay to 0:

const size = useWindowSize({ debounceDelay: 0 });

This still uses setTimeout(..., 0) which defers to the next tick. For truly synchronous updates, remove the setTimeout from the hook.

What does initialSize do and when is it used?
  • initialSize provides the dimensions returned during SSR (default: { width: 0, height: 0 }).
  • On the client, the effect immediately updates to real dimensions.
  • Pass a reasonable estimate (e.g., { width: 1024, height: 768 }) to reduce layout shift.
Why does the effect set size immediately on mount in addition to listening for resize?

The component may have been unmounted and remounted while the window was resized. The immediate set ensures the state is correct even if no resize event fires after mount.

Gotcha: My SSR page shows a layout shift because the server renders with width 0. How do I fix this?
  • Pass a realistic initialSize that matches your most common viewport.
  • For critical layouts, prefer CSS media queries that do not depend on JavaScript.
  • Use useMediaQuery for boolean breakpoint checks that can tolerate the brief flash.
Gotcha: Opening the keyboard on mobile triggers a resize event and re-renders my layout. Why?

On mobile browsers, the virtual keyboard resizes the viewport. The hook fires on any resize, including keyboard open/close. Use window.visualViewport API to distinguish keyboard events from actual window resizes.

How does this hook differ from useMediaQuery for responsive layouts?
  • useWindowSize returns exact pixel values (width, height).
  • useMediaQuery returns a boolean for a specific breakpoint.
  • Use useWindowSize when you need calculations (e.g., canvas sizing, column counts).
  • Use useMediaQuery when you only need a boolean toggle.
Can I track the full document scroll height instead of the viewport?

Yes. Replace window.innerWidth/Height with document.documentElement.scrollWidth/Height inside the resize handler. This gives the total document size including overflow.

What is the TypeScript type of the returned object?

The hook returns WindowSize, which is { width: number; height: number }. The interface is exported so consumers can use it for their own props or state types.

How would I build a useOrientation hook on top of useWindowSize?
function useOrientation(): "landscape" | "portrait" {
  const { width, height } = useWindowSize();
  return width > height ? "landscape" : "portrait";
}