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:
useStatereadswindow.innerWidthandwindow.innerHeightonly on the client, falling back toinitialSizeduring 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
| Option | Type | Default | Description |
|---|---|---|---|
debounceDelay | number | 100 | Milliseconds to debounce resize events |
initialSize | { width, height } | { width: 0, height: 0 } | Size returned during SSR |
| Return | Type | Description |
|---|---|---|
width | number | Current window.innerWidth |
height | number | Current 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
WindowSizeinterface 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 reasonableinitialSizeestimate. - Too frequent updates — Without debouncing, every pixel of a resize drag triggers a re-render. Fix: The built-in debounce handles this; adjust
debounceDelayas needed. - Virtual keyboard on mobile — On mobile, opening the keyboard resizes the viewport and triggers the handler. Fix: Use
window.visualViewportAPI if you need to distinguish keyboard from resize. - iframe context — Inside an iframe,
window.innerWidthreflects the iframe size, not the parent window. Fix: Useparent.windowif cross-origin policy allows, or pass size as a prop.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useWindowSize | No built-in debounce |
@uidotdev/usehooks | useWindowSize | Minimal implementation |
ahooks | useSize | Tracks any element, not just window |
react-use | useWindowSize | Includes server-side defaults |
@react-hook/window-size | useWindowSize | Throttled 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?
initialSizeprovides 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
initialSizethat matches your most common viewport. - For critical layouts, prefer CSS media queries that do not depend on JavaScript.
- Use
useMediaQueryfor 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?
useWindowSizereturns exact pixel values (width,height).useMediaQueryreturns a boolean for a specific breakpoint.- Use
useWindowSizewhen you need calculations (e.g., canvas sizing, column counts). - Use
useMediaQuerywhen 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";
}Related
- useMediaQuery — boolean breakpoint checks
- useDebounce — the debounce pattern in detail
- useEventListener — declarative event binding