useWindowSize Hook: Responsive React Apps Guide (2026)
Building responsive interfaces goes beyond CSS media queries. Sometimes you need JavaScript access to viewport dimensions—maybe to calculate dynamic layouts, position floating elements, or conditionally render components based on screen size. The browser's resize event gives you this, but handling it properly in React requires more thought than you might expect.
A poorly implemented resize listener can tank performance, create memory leaks, or break server-side rendering. In this guide, you'll learn how to build a production-ready useWindowSize hook that handles all these concerns. We'll start with a basic implementation, then progressively add debouncing, SSR support, and breakpoint detection.
Table of Contents
- Why Track Window Size in React?
- Basic Window Size Hook
- Memory Leak Prevention with Cleanup
- Performance Optimization with Debouncing
- Server-Side Rendering Compatibility
- Breakpoint Detection Pattern
- TypeScript Implementation
- Practical Application: Responsive Dashboard Grid
- FAQ
Why Track Window Size in React?
CSS media queries work great for most responsive design needs. But they have limitations. You can't use them to calculate JavaScript values, conditionally run logic, or make decisions about component hierarchy based on viewport size.
Here's where JavaScript-based viewport tracking shines:
Dynamic calculations: Canvas rendering, virtual scrolling, or grid layouts that need exact pixel dimensions.
Conditional rendering: Loading different components entirely based on screen size, not just hiding/showing with CSS.
Floating elements: Tooltips, dropdowns, and modals that need to position themselves based on available space.
Analytics: Tracking how users interact with your app across different viewport sizes.
The challenge is doing this efficiently. The resize event fires constantly while the user drags their browser window—potentially hundreds of times per second. Without proper handling, you'll re-render components unnecessarily and degrade performance.
Basic Window Size Hook
Let's start simple. The most straightforward version reads window dimensions on mount and updates them when the window resizes.
JavaScript Version
import { useState, useEffect } from 'react';
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
// Set initial size
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
}
This hook initializes state with undefined dimensions, then sets up a resize listener in an effect. The handler updates state with current window dimensions. The effect returns a cleanup function that removes the listener when the component unmounts.
Why undefined initially? Because on the server (during SSR), window doesn't exist. Starting with undefined makes this explicit.
TypeScript Version
import { useState, useEffect } from 'react';
interface WindowSize {
width: number | undefined;
height: number | undefined;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: undefined,
height: undefined
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
}
The TypeScript version adds type safety with a WindowSize interface. The number | undefined union type ensures you handle the case where dimensions haven't loaded yet.
Using the Hook
function App() {
const { width, height } = useWindowSize();
return (
<div>
<h1>Window Dimensions</h1>
<p>Width: {width}px</p>
<p>Height: {height}px</p>
</div>
);
}
Simple enough. But this implementation has problems. Let's fix them.
Memory Leak Prevention with Cleanup
The basic version includes cleanup, which is crucial. Without it, you'd create memory leaks every time a component using the hook unmounts.
Here's what happens without cleanup:
// ❌ Memory leak version - DON'T DO THIS
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
handleResize();
window.addEventListener('resize', handleResize);
// Missing cleanup function!
}, []);
return windowSize;
}
Each time a component using this hook mounts, it adds a new resize listener. When the component unmounts, the listener stays attached. Mount and unmount the component 10 times? You've got 10 listeners all trying to update state in unmounted components.
React will warn you about this with "Can't perform a React state update on an unmounted component." But the real issue is memory accumulation. In a long-running single-page app, this adds up.
The cleanup function prevents this:
return () => window.removeEventListener('resize', handleResize);
React calls this function when the component unmounts, removing the listener and preventing the memory leak.
Performance Optimization with Debouncing
The resize event fires rapidly. Dragging a window from 1920px to 1000px might trigger it 100+ times. Each event causes a state update and re-render. For simple UIs this might be fine, but complex components will lag.
Debouncing solves this by waiting until the user stops resizing before updating state.
JavaScript Version with Debounce
import { useState, useEffect } from 'react';
export function useWindowSize(delay = 150) {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined
});
useEffect(() => {
let timeoutId;
function handleResize() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}, delay);
}
// Set initial size immediately (no debounce)
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, [delay]);
return windowSize;
}
Now the resize handler clears any pending timeout and sets a new one. Only after the user stops resizing for 150ms does the state update fire.
Notice we still update immediately on mount—users shouldn't wait for the debounce delay to see initial values.
The cleanup function clears both the timeout and the event listener. This prevents the timeout from trying to update state after unmount.
TypeScript Version with Debounce
import { useState, useEffect } from 'react';
interface WindowSize {
width: number | undefined;
height: number | undefined;
}
export function useWindowSize(delay: number = 150): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: undefined,
height: undefined
});
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
function handleResize() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}, delay);
}
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [delay]);
return windowSize;
}
The TypeScript version types timeoutId as NodeJS.Timeout | undefined for proper type checking.
Server-Side Rendering Compatibility
If you're using Next.js, Remix, or another SSR framework, the basic implementation breaks because window doesn't exist on the server.
The fix is checking for window before accessing it:
JavaScript Version with SSR Support
import { useState, useEffect } from 'react';
function getWindowSize() {
if (typeof window === 'undefined') {
return { width: undefined, height: undefined };
}
return {
width: window.innerWidth,
height: window.innerHeight
};
}
export function useWindowSize(delay = 150) {
const [windowSize, setWindowSize] = useState(getWindowSize());
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId;
function handleResize() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setWindowSize(getWindowSize());
}, delay);
}
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, [delay]);
return windowSize;
}
The getWindowSize helper checks for window existence. During SSR, it returns undefined dimensions. On the client, it returns actual values.
The effect also checks for window and returns early if it doesn't exist, preventing server-side errors.
TypeScript Version with SSR Support
import { useState, useEffect } from 'react';
interface WindowSize {
width: number | undefined;
height: number | undefined;
}
function getWindowSize(): WindowSize {
if (typeof window === 'undefined') {
return { width: undefined, height: undefined };
}
return {
width: window.innerWidth,
height: window.innerHeight
};
}
export function useWindowSize(delay: number = 150): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>(getWindowSize());
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId: NodeJS.Timeout | undefined;
function handleResize() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setWindowSize(getWindowSize());
}, delay);
}
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [delay]);
return windowSize;
}
Breakpoint Detection Pattern
Often you don't need exact pixel dimensions—you just want to know if you're on mobile, tablet, or desktop. This pattern adds breakpoint detection.
TypeScript Version with Breakpoints
import { useState, useEffect } from 'react';
interface WindowSize {
width: number | undefined;
height: number | undefined;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
}
interface Breakpoints {
mobile: number;
tablet: number;
desktop: number;
}
const defaultBreakpoints: Breakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1280
};
function getWindowSize(breakpoints: Breakpoints): WindowSize {
if (typeof window === 'undefined') {
return {
width: undefined,
height: undefined,
isMobile: false,
isTablet: false,
isDesktop: false
};
}
const width = window.innerWidth;
const height = window.innerHeight;
return {
width,
height,
isMobile: width < breakpoints.mobile,
isTablet: width >= breakpoints.mobile && width < breakpoints.tablet,
isDesktop: width >= breakpoints.tablet
};
}
export function useWindowSize(
delay: number = 150,
breakpoints: Breakpoints = defaultBreakpoints
): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>(() =>
getWindowSize(breakpoints)
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId: NodeJS.Timeout | undefined;
function handleResize() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setWindowSize(getWindowSize(breakpoints));
}, delay);
}
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [delay, breakpoints]);
return windowSize;
}
JavaScript Version with Breakpoints
import { useState, useEffect } from 'react';
const defaultBreakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1280
};
function getWindowSize(breakpoints) {
if (typeof window === 'undefined') {
return {
width: undefined,
height: undefined,
isMobile: false,
isTablet: false,
isDesktop: false
};
}
const width = window.innerWidth;
const height = window.innerHeight;
return {
width,
height,
isMobile: width < breakpoints.mobile,
isTablet: width >= breakpoints.mobile && width < breakpoints.tablet,
isDesktop: width >= breakpoints.tablet
};
}
export function useWindowSize(delay = 150, breakpoints = defaultBreakpoints) {
const [windowSize, setWindowSize] = useState(() =>
getWindowSize(breakpoints)
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let timeoutId;
function handleResize() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setWindowSize(getWindowSize(breakpoints));
}, delay);
}
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, [delay, breakpoints]);
return windowSize;
}
Now you can write cleaner conditional logic:
function ResponsiveComponent() {
const { isMobile, isTablet, isDesktop } = useWindowSize();
if (isMobile) {
return <MobileView />;
}
if (isTablet) {
return <TabletView />;
}
return <DesktopView />;
}
TypeScript Implementation
Here's the complete production-ready version with all features combined:
import { useState, useEffect } from 'react';
interface WindowSize {
width: number | undefined;
height: number | undefined;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
}
interface Breakpoints {
mobile: number;
tablet: number;
desktop: number;
}
interface UseWindowSizeOptions {
delay?: number;
breakpoints?: Breakpoints;
initializeOnClient?: boolean;
}
const defaultBreakpoints: Breakpoints = {
mobile: 768,
tablet: 1024,
desktop: 1280
};
function getWindowSize(breakpoints: Breakpoints): WindowSize {
if (typeof window === 'undefined') {
return {
width: undefined,
height: undefined,
isMobile: false,
isTablet: false,
isDesktop: false
};
}
const width = window.innerWidth;
const height = window.innerHeight;
return {
width,
height,
isMobile: width < breakpoints.mobile,
isTablet: width >= breakpoints.mobile && width < breakpoints.tablet,
isDesktop: width >= breakpoints.tablet
};
}
export function useWindowSize(options: UseWindowSizeOptions = {}): WindowSize {
const {
delay = 150,
breakpoints = defaultBreakpoints,
initializeOnClient = true
} = options;
const [windowSize, setWindowSize] = useState<WindowSize>(() => {
if (!initializeOnClient && typeof window !== 'undefined') {
return getWindowSize(breakpoints);
}
return {
width: undefined,
height: undefined,
isMobile: false,
isTablet: false,
isDesktop: false
};
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
// Initialize on client if needed
if (initializeOnClient) {
setWindowSize(getWindowSize(breakpoints));
}
let timeoutId: NodeJS.Timeout | undefined;
function handleResize() {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setWindowSize(getWindowSize(breakpoints));
}, delay);
}
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [delay, breakpoints, initializeOnClient]);
return windowSize;
}
This version adds:
Options object: Configure delay, breakpoints, and initialization behavior Flexible initialization: Choose whether to set dimensions immediately or wait for client-side hydration Complete type safety: Full TypeScript types for all configuration options
Practical Application: Responsive Dashboard Grid
Let's build a real-world example: a dashboard that adapts its layout based on viewport size. On mobile, cards stack vertically. On tablet, they arrange in a 2-column grid. On desktop, they spread across 3 or 4 columns.
TypeScript Implementation
import { useWindowSize } from './useWindowSize';
interface MetricCardProps {
title: string;
value: string | number;
change: number;
icon: string;
}
function MetricCard({ title, value, change, icon }: MetricCardProps) {
const isPositive = change >= 0;
return (
<div className="metric-card">
<div className="metric-header">
<span className="metric-icon">{icon}</span>
<h3 className="metric-title">{title}</h3>
</div>
<div className="metric-value">{value}</div>
<div className={`metric-change ${isPositive ? 'positive' : 'negative'}`}>
{isPositive ? '↑' : '↓'} {Math.abs(change)}%
</div>
</div>
);
}
function Dashboard() {
const { width, isMobile, isTablet, isDesktop } = useWindowSize({
delay: 100,
breakpoints: {
mobile: 640,
tablet: 1024,
desktop: 1440
}
});
// Determine grid columns based on viewport
const gridColumns = isMobile ? 1 : isTablet ? 2 : isDesktop ? 3 : 4;
const metrics = [
{ title: '日活跃用户', value: '28,547', change: 12.5, icon: '👥' },
{ title: '今日收入', value: '¥156,890', change: -3.2, icon: '💰' },
{ title: '转化率', value: '4.86%', change: 8.1, icon: '📈' },
{ title: '平均停留时长', value: '5m 42s', change: 15.3, icon: '⏱️' },
{ title: '新增注册', value: '1,234', change: 22.7, icon: '✨' },
{ title: '退款率', value: '0.8%', change: -12.4, icon: '🔄' }
];
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>数据分析看板</h1>
<div className="viewport-info">
{width && (
<span className="viewport-badge">
{isMobile && '📱 移动端'}
{isTablet && '📱 平板'}
{isDesktop && '💻 桌面端'}
{' '}{width}px
</span>
)}
</div>
</header>
<main
className="metrics-grid"
style={{
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: isMobile ? '1rem' : '1.5rem'
}}
>
{metrics.map((metric, index) => (
<MetricCard key={index} {...metric} />
))}
</main>
{isMobile && (
<div className="mobile-notice">
<p>💡 在更大的屏幕上查看完整仪表板体验</p>
</div>
)}
</div>
);
}
export default Dashboard;
JavaScript Implementation
import { useWindowSize } from './useWindowSize';
function MetricCard({ title, value, change, icon }) {
const isPositive = change >= 0;
return (
<div className="metric-card">
<div className="metric-header">
<span className="metric-icon">{icon}</span>
<h3 className="metric-title">{title}</h3>
</div>
<div className="metric-value">{value}</div>
<div className={`metric-change ${isPositive ? 'positive' : 'negative'}`}>
{isPositive ? '↑' : '↓'} {Math.abs(change)}%
</div>
</div>
);
}
function Dashboard() {
const { width, isMobile, isTablet, isDesktop } = useWindowSize({
delay: 100,
breakpoints: {
mobile: 640,
tablet: 1024,
desktop: 1440
}
});
const gridColumns = isMobile ? 1 : isTablet ? 2 : isDesktop ? 3 : 4;
const metrics = [
{ title: '日活跃用户', value: '28,547', change: 12.5, icon: '👥' },
{ title: '今日收入', value: '¥156,890', change: -3.2, icon: '💰' },
{ title: '转化率', value: '4.86%', change: 8.1, icon: '📈' },
{ title: '平均停留时长', value: '5m 42s', change: 15.3, icon: '⏱️' },
{ title: '新增注册', value: '1,234', change: 22.7, icon: '✨' },
{ title: '退款率', value: '0.8%', change: -12.4, icon: '🔄' }
];
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>数据分析看板</h1>
<div className="viewport-info">
{width && (
<span className="viewport-badge">
{isMobile && '📱 移动端'}
{isTablet && '📱 平板'}
{isDesktop && '💻 桌面端'}
{' '}{width}px
</span>
)}
</div>
</header>
<main
className="metrics-grid"
style={{
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: isMobile ? '1rem' : '1.5rem'
}}
>
{metrics.map((metric, index) => (
<MetricCard key={index} {...metric} />
))}
</main>
{isMobile && (
<div className="mobile-notice">
<p>💡 在更大的屏幕上查看完整仪表板体验</p>
</div>
)}
</div>
);
}
export default Dashboard;
This dashboard demonstrates several practical patterns:
Dynamic grid layout: The number of columns changes based on breakpoints, giving each screen size an optimal viewing experience.
Conditional UI elements: The viewport badge shows current screen size, and mobile users see an extra notice about the desktop experience.
Performance considerations: Using a 100ms debounce delay prevents excessive re-renders during window resizing.
Real-world data: Metrics include both positive and negative trends with Chinese labels, reflecting actual business dashboard needs.
You'd pair this with CSS for styling:
.metrics-grid {
display: grid;
margin-top: 2rem;
}
.metric-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.metric-value {
font-size: 2rem;
font-weight: bold;
margin: 0.5rem 0;
}
.metric-change.positive {
color: #10b981;
}
.metric-change.negative {
color: #ef4444;
}
FAQ
Should I use useWindowSize or CSS media queries?
Use CSS media queries for visual styling changes like font sizes, spacing, and layout shifts. Use useWindowSize when you need JavaScript logic based on viewport size—calculations, conditional rendering, or dynamic behaviors that CSS can't handle.
Why does my component flash on initial render with SSR?
During SSR, window dimensions are undefined. When the component hydrates on the client, dimensions update, causing a re-render. This is expected. You can minimize the flash by setting initializeOnClient: false and providing sensible defaults for your initial render.
What's the difference between debouncing and throttling for resize events?
Debouncing waits until the user stops resizing before firing the callback. Throttling fires the callback at regular intervals while resizing continues. For viewport tracking, debouncing usually works better—you care about the final size, not intermediate states.
Can I use window.matchMedia instead for better performance?
Yes! matchMedia is more efficient for breakpoint detection because it uses the browser's native media query engine. But it only works for predefined breakpoints, not exact pixel values. If you just need to know "is mobile/tablet/desktop," matchMedia is a great choice.
How do I handle orientation changes on mobile?
The resize event fires on orientation changes, so the hook already handles this. If you need explicit orientation tracking, you can extend the hook to also listen to the orientationchange event and include portrait/landscape in the returned state.
Related Articles:
- useEffect Deep Dive: Side Effects and Cleanup
- useState Basics: Complete Guide
- Debouncing and Throttling in React
Questions? Share your responsive design patterns in the comments below!
Google AdSense Placeholder
CONTENT SLOT