A type-safe React hook that listens to CSS media queries and returns a boolean indicating whether the query matches. Handles SSR gracefully and cleans up listeners automatically.
import { useState, useEffect, useCallback } from 'react';
/**
* Options for the useMediaQuery hook
*/
interface UseMediaQueryOptions {
/** Default value to use during SSR or before hydration */
defaultValue?: boolean;
/** Whether to initialize with the actual value on first client render */
initializeWithValue?: boolean;
}
/**
* Checks if code is running in a browser environment
*/
const getIsClient = (): boolean => {
return typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';
};
/**
* A React hook that tracks whether a CSS media query matches.
* SSR-safe and automatically updates when the media query state changes.
*
* @param query - The media query string to evaluate
* @param options - Configuration options
* @returns boolean indicating whether the media query matches
*/
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions = {}
): boolean {
const { defaultValue = false, initializeWithValue = true } = options;
const getMatches = useCallback((mediaQuery: string): boolean => {
if (!getIsClient()) {
return defaultValue;
}
return window.matchMedia(mediaQuery).matches;
}, [defaultValue]);
const [matches, setMatches] = useState<boolean>(() => {
if (initializeWithValue) {
return getMatches(query);
}
return defaultValue;
});
useEffect(() => {
if (!getIsClient()) {
return;
}
const mediaQueryList = window.matchMedia(query);
// Set initial value on mount (handles SSR hydration)
const initialMatch = mediaQueryList.matches;
if (initialMatch !== matches) {
setMatches(initialMatch);
}
// Event handler for media query changes
const handleChange = (event: MediaQueryListEvent): void => {
setMatches(event.matches);
};
// Modern browsers use addEventListener, older ones use addListener
if (mediaQueryList.addEventListener) {
mediaQueryList.addEventListener('change', handleChange);
} else {
// Fallback for older browsers (Safari < 14)
mediaQueryList.addListener(handleChange);
}
// Cleanup listener on unmount or query change
return () => {
if (mediaQueryList.removeEventListener) {
mediaQueryList.removeEventListener('change', handleChange);
} else {
mediaQueryList.removeListener(handleChange);
}
};
}, [query, getMatches, matches]);
return matches;
}
// ============================================
// PREDEFINED BREAKPOINT HOOKS
// ============================================
/** Common breakpoint values (matches Tailwind CSS defaults) */
export const breakpoints = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
} as const;
export type Breakpoint = keyof typeof breakpoints;
/**
* Hook to check if viewport is at or above a specific breakpoint
*/
export function useBreakpoint(breakpoint: Breakpoint): boolean {
return useMediaQuery(`(min-width: ${breakpoints[breakpoint]})`);
}
/**
* Hook to check if viewport is below a specific breakpoint
*/
export function useBreakpointMax(breakpoint: Breakpoint): boolean {
const value = parseInt(breakpoints[breakpoint], 10) - 1;
return useMediaQuery(`(max-width: ${value}px)`);
}
// ============================================
// ACCESSIBILITY PREFERENCE HOOKS
// ============================================
/**
* Hook to detect if user prefers reduced motion
* Useful for disabling animations for users with vestibular disorders
*/
export function usePrefersReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}
/**
* Hook to detect if user prefers dark color scheme
*/
export function usePrefersDarkMode(): boolean {
return useMediaQuery('(prefers-color-scheme: dark)');
}
/**
* Hook to detect if user prefers light color scheme
*/
export function usePrefersLightMode(): boolean {
return useMediaQuery('(prefers-color-scheme: light)');
}
/**
* Hook to detect if user prefers high contrast
*/
export function usePrefersHighContrast(): boolean {
return useMediaQuery('(prefers-contrast: more)');
}
// ============================================
// USAGE EXAMPLES
// ============================================
/*
import React from 'react';
import {
useMediaQuery,
useBreakpoint,
usePrefersReducedMotion,
usePrefersDarkMode,
} from './useMediaQuery';
// Example 1: Basic media query usage
function ResponsiveComponent() {
const isLargeScreen = useMediaQuery('(min-width: 1024px)');
const isPortrait = useMediaQuery('(orientation: portrait)');
return (
<div>
<p>Screen size: {isLargeScreen ? 'Large' : 'Small'}</p>
<p>Orientation: {isPortrait ? 'Portrait' : 'Landscape'}</p>
</div>
);
}
// Example 2: Using predefined breakpoints
function NavigationComponent() {
const isMobile = !useBreakpoint('md');
const isDesktop = useBreakpoint('lg');
if (isMobile) {
return <MobileNav />;
}
return <DesktopNav showExtended={isDesktop} />;
}
// Example 3: Accessibility - Reduced Motion
function AnimatedComponent() {
const prefersReducedMotion = usePrefersReducedMotion();
const animationStyle = prefersReducedMotion
? { transition: 'none' }
: { transition: 'transform 0.3s ease-in-out' };
return (
<div style={animationStyle}>
{prefersReducedMotion
? 'Animations disabled'
: 'Smooth animations enabled'}
</div>
);
}
// Example 4: Dark mode detection
function ThemeAwareComponent() {
const prefersDark = usePrefersDarkMode();
return (
<div className={prefersDark ? 'dark-theme' : 'light-theme'}>
System preference: {prefersDark ? 'Dark Mode' : 'Light Mode'}
</div>
);
}
// Example 5: SSR with custom default value
function SSRSafeComponent() {
// Default to mobile-first during SSR
const isDesktop = useMediaQuery('(min-width: 1024px)', {
defaultValue: false,
initializeWithValue: true,
});
return (
<div>
{isDesktop ? <DesktopLayout /> : <MobileLayout />}
</div>
);
}
// Example 6: Combining multiple queries
function ComplexResponsiveComponent() {
const isTabletOrLarger = useBreakpoint('md');
const prefersReducedMotion = usePrefersReducedMotion();
const prefersDark = usePrefersDarkMode();
const shouldAnimate = isTabletOrLarger && !prefersReducedMotion;
return (
<section
data-animate={shouldAnimate}
data-theme={prefersDark ? 'dark' : 'light'}
>
<h1>Adaptive Content</h1>
<p>Device: {isTabletOrLarger ? 'Tablet/Desktop' : 'Mobile'}</p>
<p>Animations: {shouldAnimate ? 'Enabled' : 'Disabled'}</p>
<p>Theme: {prefersDark ? 'Dark' : 'Light'}</p>
</section>
);
}
*/This useMediaQuery hook provides a robust, production-ready solution for responding to CSS media queries in React applications. The core implementation uses the browser's window.matchMedia API, which allows JavaScript to evaluate media queries and receive updates when they change. The hook returns a simple boolean that re-renders the component whenever the media query's match state changes.
SSR safety is achieved through careful environment detection using the getIsClient helper function. During server-side rendering, window is undefined, so the hook returns the defaultValue (which defaults to false). The initializeWithValue option controls whether the hook immediately evaluates the query on the client or waits for the effect to run. This flexibility helps prevent hydration mismatches in frameworks like Next.js—you might set initializeWithValue: false if you expect server and initial client render to differ.
The hook includes backward compatibility for older browsers (specifically Safari versions before 14) that use the deprecated addListener/removeListener methods instead of the standard addEventListener/removeEventListener. The cleanup function properly removes the listener when the component unmounts or when the query string changes, preventing memory leaks.
Beyond the base hook, the code provides several convenience hooks for common use cases. The breakpoint hooks (useBreakpoint, useBreakpointMax) use Tailwind CSS's default breakpoint values, making them immediately useful in Tailwind projects. The accessibility hooks (usePrefersReducedMotion, usePrefersDarkMode, usePrefersHighContrast) help you respect user system preferences—particularly important for users with vestibular disorders who may experience motion sickness from animations.
Use this hook whenever you need JavaScript logic to respond to viewport changes or user preferences. Avoid it for purely visual responsive changes that can be handled with CSS media queries alone—CSS-only solutions are more performant since they don't cause re-renders. The hook is ideal for conditionally rendering entirely different component trees, lazy-loading heavy components on larger screens, or adjusting animation libraries that don't support CSS media queries.