useMediaQuery Hook: Responsive Design in React Components
Building truly responsive React applications means your components need to adapt to more than just viewport size—they need to respond to device capabilities, color preferences, and user settings in real-time. The useMediaQuery hook bridges the gap between CSS media queries and React's component logic, letting you use media queries directly inside your components to control behavior, not just styling.
Without this abstraction, responsive logic ends up scattered: CSS handles visual breakpoints, JavaScript adds event listeners for window resizing, and state management gets messy. A proper useMediaQuery hook centralizes this, making your components aware of media conditions at runtime.
Table of Contents
- The Problem with Raw Media Queries
- Basic useMediaQuery Implementation
- Advanced Hook Patterns
- Handling SSR and Hydration
- Practical Application Scenarios
- Performance Optimization Strategies
- FAQ
The Problem with Raw Media Queries
Most developers handle responsive design purely in CSS:
/* styles.css */
.sidebar {
width: 300px;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
}
}
But what if your component logic needs to know the breakpoint? For instance, rendering different components entirely on mobile vs. desktop—not just styling them differently:
// ❌ This doesn't work - you can't access CSS media queries in JS
function AdaptiveLayout() {
if (isMobile) { // How do we detect this?
return <MobileLayout />;
}
return <DesktopLayout />;
}
Developers resort to hacky workarounds: reading DOM computed styles, manually tracking window size, or duplicating breakpoint constants between CSS and JavaScript. Each approach introduces maintenance burden and the potential for misalignment.
The useMediaQuery hook solves this elegantly—it makes CSS media queries directly queryable in React.
Basic useMediaQuery Implementation
TypeScript Version
Here's the foundational implementation:
import { useEffect, useState } from 'react';
interface UseMediaQueryOptions {
// Set initial value for SSR (optional)
initialValue?: boolean;
// Override the device to match (for testing)
ssrMatchMedia?: boolean;
}
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions = {}
): boolean {
const { initialValue = false, ssrMatchMedia } = options;
// Determine initial state (crucial for SSR)
const [matches, setMatches] = useState<boolean>(() => {
// If in browser, actually test the media query
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia(query).matches;
}
// Fall back to initial value or ssrMatchMedia
return ssrMatchMedia ?? initialValue;
});
useEffect(() => {
// Ensure we're in browser
if (typeof window === 'undefined') {
return;
}
// Get the MediaQueryList object
const mediaQuery = window.matchMedia(query);
// Handler for media query changes
const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
// Modern browsers: use addEventListener (preferred)
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}
// Legacy fallback for older browsers
mediaQuery.addListener(handleChange);
return () => {
mediaQuery.removeListener(handleChange);
};
}, [query]);
return matches;
}
JavaScript Version
export function useMediaQuery(query, options = {}) {
const { initialValue = false, ssrMatchMedia } = options;
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia(query).matches;
}
return ssrMatchMedia ?? initialValue;
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia(query);
const handleChange = (e) => {
setMatches(e.matches);
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}
mediaQuery.addListener(handleChange);
return () => {
mediaQuery.removeListener(handleChange);
};
}, [query]);
return matches;
}
Advanced Hook Patterns
Multi-Query Hook with Breakpoints
Real applications need multiple breakpoints. This hook manages an entire breakpoint system:
interface Breakpoints {
small: string;
medium: string;
large: string;
xl: string;
[key: string]: string;
}
interface BreakpointMatches {
[key: string]: boolean;
}
export function useBreakpoints(
breakpoints: Breakpoints = {
small: '(min-width: 640px)',
medium: '(min-width: 768px)',
large: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
}
): BreakpointMatches {
const [matches, setMatches] = useState<BreakpointMatches>(() => {
if (typeof window === 'undefined') {
return Object.keys(breakpoints).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{}
);
}
return Object.entries(breakpoints).reduce(
(acc, [key, query]) => ({
...acc,
[key]: window.matchMedia(query).matches,
}),
{} as BreakpointMatches
);
});
useEffect(() => {
if (typeof window === 'undefined') return;
// Track all media queries
const mediaQueries = Object.entries(breakpoints).map(([key, query]) => {
const mq = window.matchMedia(query);
return { key, mq };
});
// Update state when any breakpoint changes
const handleChange = () => {
setMatches(
mediaQueries.reduce(
(acc, { key, mq }) => ({
...acc,
[key]: mq.matches,
}),
{}
)
);
};
// Attach listeners
mediaQueries.forEach(({ mq }) => {
if (mq.addEventListener) {
mq.addEventListener('change', handleChange);
} else {
mq.addListener(handleChange);
}
});
// Cleanup
return () => {
mediaQueries.forEach(({ mq }) => {
if (mq.removeEventListener) {
mq.removeEventListener('change', handleChange);
} else {
mq.removeListener(handleChange);
}
});
};
}, [JSON.stringify(breakpoints)]);
return matches;
}
JavaScript Version
export function useBreakpoints(
breakpoints = {
small: '(min-width: 640px)',
medium: '(min-width: 768px)',
large: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
}
) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') {
return Object.keys(breakpoints).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{}
);
}
return Object.entries(breakpoints).reduce(
(acc, [key, query]) => ({
...acc,
[key]: window.matchMedia(query).matches,
}),
{}
);
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQueries = Object.entries(breakpoints).map(([key, query]) => {
const mq = window.matchMedia(query);
return { key, mq };
});
const handleChange = () => {
setMatches(
mediaQueries.reduce(
(acc, { key, mq }) => ({
...acc,
[key]: mq.matches,
}),
{}
)
);
};
mediaQueries.forEach(({ mq }) => {
if (mq.addEventListener) {
mq.addEventListener('change', handleChange);
} else {
mq.addListener(handleChange);
}
});
return () => {
mediaQueries.forEach(({ mq }) => {
if (mq.removeEventListener) {
mq.removeEventListener('change', handleChange);
} else {
mq.removeListener(handleChange);
}
});
};
}, [JSON.stringify(breakpoints)]);
return matches;
}
Specialized Hooks for Common Queries
Build higher-level hooks for frequently used patterns:
// Detect dark mode preference
export function useDarkMode(): boolean {
return useMediaQuery('(prefers-color-scheme: dark)');
}
// Detect touch devices
export function useIsTouchDevice(): boolean {
return useMediaQuery('(hover: none) and (pointer: coarse)');
}
// Detect if device motion is available
export function useMotionAllowed(): boolean {
return useMediaQuery('(prefers-reduced-motion: no-preference)');
}
// Common breakpoints (matches Tailwind defaults)
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 640px)');
}
export function useIsTablet(): boolean {
return useMediaQuery('(min-width: 641px) and (max-width: 1024px)');
}
export function useIsDesktop(): boolean {
return useMediaQuery('(min-width: 1025px)');
}
Handling SSR and Hydration
Server-side rendering requires careful handling to prevent hydration mismatches:
interface UseMediaQuerySSROptions {
// Assume this value on server (prevents hydration mismatch)
defaultValue?: boolean;
// Server-side query result (e.g., from headers)
serverValue?: boolean;
}
export function useMediaQuery(
query: string,
options: UseMediaQuerySSROptions = {}
): boolean {
const { defaultValue = false, serverValue } = options;
// Use serverValue if provided, otherwise defaultValue
const [matches, setMatches] = useState<boolean>(
serverValue !== undefined ? serverValue : defaultValue
);
// Track if component is mounted (for hydration safety)
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
// Update to actual browser value after hydration
setMatches(mediaQuery.matches);
const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}
mediaQuery.addListener(handleChange);
return () => {
mediaQuery.removeListener(handleChange);
};
}, [query]);
// Return defaultValue during SSR, actual value after hydration
return isMounted ? matches : (serverValue ?? defaultValue);
}
Practical Application Scenarios
Scenario 1: Responsive Navigation with Adaptive Menus
Different navigation strategies for mobile vs. desktop—not just styling:
export function Navigation() {
const isMobile = useMediaQuery('(max-width: 768px)');
const [menuOpen, setMenuOpen] = useState(false);
// Close menu when transitioning to desktop
useEffect(() => {
if (!isMobile) {
setMenuOpen(false);
}
}, [isMobile]);
if (isMobile) {
return (
<nav>
<button onClick={() => setMenuOpen(!menuOpen)}>
{menuOpen ? '✕' : '☰'} Menu
</button>
{menuOpen && (
<ul className="mobile-menu">
<li><a href="/">Home</a></li>
<li><a href="/docs">Docs</a></li>
<li><a href="/about">About</a></li>
</ul>
)}
</nav>
);
}
return (
<nav className="desktop-nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/docs">Docs</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
);
}
Scenario 2: Dark Mode Toggle with System Preference
Respect user's system preference while allowing overrides:
type Theme = 'light' | 'dark' | 'system';
export function ThemeProvider({ children }: { children: ReactNode }) {
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
const [theme, setTheme] = useState<Theme>('system');
const isDark = theme === 'system' ? prefersDark : theme === 'dark';
useEffect(() => {
// Apply theme to document
document.documentElement.classList.toggle('dark', isDark);
// Update meta tags
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute(
'content',
isDark ? '#1a1a1a' : '#ffffff'
);
}
}, [isDark]);
return (
<ThemeContext.Provider value={{ theme, setTheme, isDark }}>
{children}
</ThemeContext.Provider>
);
}
// Component that uses theme context
export function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
onClick={() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
}}
>
{theme === 'dark' ? '🌙' : '☀️'} Theme
</button>
);
}
Scenario 3: Accessibility-Aware Animations
Respect user's motion preferences (critical for accessibility):
export function AnimatedCard({ children }: { children: ReactNode }) {
const prefersMotion = useMediaQuery('(prefers-reduced-motion: no-preference)');
const animationClass = prefersMotion ? 'fade-in-animate' : 'fade-in-instant';
return (
<div className={animationClass}>
{children}
</div>
);
}
// CSS
// .fade-in-animate { animation: fadeIn 0.3s ease-in; }
// .fade-in-instant { animation: none; opacity: 1; }
// Or inline styles
export function AnimatedCardInline({ children }: { children: ReactNode }) {
const prefersMotion = useMediaQuery('(prefers-reduced-motion: no-preference)');
const style = prefersMotion
? { animation: 'fadeIn 0.3s ease-in' }
: { opacity: 1, animation: 'none' };
return <div style={style}>{children}</div>;
}
Scenario 4: Conditional Image Loading
Load high-resolution images only on capable devices:
interface ResponsiveImageProps {
src: string;
highResSrc: string;
alt: string;
}
export function ResponsiveImage({
src,
highResSrc,
alt,
}: ResponsiveImageProps) {
// Only load high-res on larger screens
const isHighRes = useMediaQuery('(min-width: 1280px)');
const isRetina = useMediaQuery('(min-resolution: 2dppx)');
const imageSrc = isHighRes && isRetina ? highResSrc : src;
return <img src={imageSrc} alt={alt} />;
}
Performance Optimization Strategies
Memoizing Hooks to Prevent Cascading Updates
When multiple components use the same query, memoize the result:
import { useMemo } from 'react';
// Cache media query results
const mediaQueryCache = new Map<string, boolean>();
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
if (mediaQueryCache.has(query)) {
return mediaQueryCache.get(query)!;
}
const mq = window.matchMedia(query);
mediaQueryCache.set(query, mq.matches);
return mq.matches;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
const handleChange = (e: MediaQueryListEvent) => {
setMatches(e.matches);
mediaQueryCache.set(query, e.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, [query]);
return matches;
}
Debouncing Media Query Changes
Prevent unnecessary renders during rapid window resizing:
import { useRef } from 'react';
export function useMediaQueryDebounced(
query: string,
debounceMs: number = 150
): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
const handleChange = (e: MediaQueryListEvent) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setMatches(e.matches);
}, debounceMs);
};
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
clearTimeout(timeoutRef.current);
};
}, [query, debounceMs]);
return matches;
}
Reducing Re-renders with useMemo
Combine with useMemo to prevent unnecessary component re-renders:
interface LayoutConfig {
columns: number;
gap: number;
showSidebar: boolean;
}
export function useResponsiveLayout(): LayoutConfig {
const isMobile = useMediaQuery('(max-width: 640px)');
const isTablet = useMediaQuery('(min-width: 641px) and (max-width: 1024px)');
// Only create new object when breakpoint actually changes
return useMemo(
() => ({
columns: isMobile ? 1 : isTablet ? 2 : 3,
gap: isMobile ? 8 : 16,
showSidebar: !isMobile,
}),
[isMobile, isTablet]
);
}
FAQ
Q: What's the difference between matchMedia and CSS media queries?
A: matchMedia is the JavaScript API that reads CSS media query syntax. They use the exact same syntax, but matchMedia lets you query dynamically in React while CSS handles styling. Use both: CSS for visual styling, useMediaQuery for component logic.
Q: Can I use complex media queries like (min-width: 768px) and (orientation: landscape)?
A: Yes, pass any valid CSS media query string:
const isWideAndLandscape = useMediaQuery(
'(min-width: 768px) and (orientation: landscape)'
);
Q: How do I avoid hydration mismatches in Next.js?
A: Use the SSR-safe version with defaultValue:
const isMobile = useMediaQuery('(max-width: 768px)', {
defaultValue: false, // Assume desktop on server
});
Or check if mounted:
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);
return isMounted && isMobile ? <MobileLayout /> : <DesktopLayout />;
Q: What media query features does matchMedia support?
A: All standard CSS media queries: min-width, max-width, orientation, prefers-color-scheme, prefers-reduced-motion, pointer, hover, resolution, color, display-mode, and more. Check MDN's comprehensive list.
Q: Should I use useMediaQuery or CSS media queries?
A: Use both strategically:
- CSS media queries: For styling adjustments (font size, padding, display)
- useMediaQuery: For component logic (render different components, conditional features, lazy loading)
Mixing both keeps your code clean—style changes in CSS, behavior changes in React.
Q: How do I test components using useMediaQuery?
A: Mock window.matchMedia in your test setup:
// In your test file
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: query === '(max-width: 768px)', // Simulate mobile
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Now test the component
render(<YourComponent />);
expect(screen.getByText('Mobile Layout')).toBeInTheDocument();
Q: What happens if I change the media query string dynamically?
A: The hook will re-attach listeners and re-evaluate:
const [breakpoint, setBreakpoint] = useState('(max-width: 768px)');
const matches = useMediaQuery(breakpoint);
// Changing breakpoint updates the query automatically
setBreakpoint('(max-width: 1024px)');
Common Pitfalls and Solutions
Pitfall 1: Hydration Mismatch
// ❌ WRONG: Desktop renders empty, then mobile content appears
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileLayout /> : <DesktopLayout />;
// ✅ CORRECT: Provide default to prevent mismatch
const isMobile = useMediaQuery('(max-width: 768px)', { defaultValue: false });
Pitfall 2: Memory Leaks from Multiple Listeners
// ❌ WRONG: Each render re-attaches listener
useEffect(() => {
const mq = window.matchMedia(query);
mq.addEventListener('change', handler);
// Missing cleanup!
}, []);
// ✅ CORRECT: Cleanup removes listener
useEffect(() => {
const mq = window.matchMedia(query);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
Pitfall 3: Ignoring Accessibility
// ✅ GOOD: Respect motion preferences
const prefersMotion = useMediaQuery('(prefers-reduced-motion: no-preference)');
const animationClass = prefersMotion ? 'animate' : '';
Related Articles
- useEffect Hook: Managing Side Effects
- Responsive Design in React Applications
- Optimizing Render Performance
- useState Hook Fundamentals
Next Steps
The useMediaQuery hook becomes powerful when combined with:
- Context API for theming based on system preferences
- Custom breakpoint systems matching your design tokens
- Performance hooks preventing unnecessary media query evaluations
- Testing utilities for simulating different device conditions
Start by implementing dark mode detection, then expand to full responsive component logic. You'll find media queries become a natural extension of your React component architecture.
What responsive patterns do you use most? Share your useMediaQuery implementations in the comments—component-specific breakpoints, accessibility patterns, and creative media query use cases always spark great discussions among developers building for ByteDance's and Alibaba's scales.
Google AdSense Placeholder
CONTENT SLOT