AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useMediaQuery Hook: Responsive Design in React Components

Last updated:
useMediaQuery Hook: Responsive Design in React Components

Build responsive React apps with useMediaQuery. Master media query hooks for dark mode detection, breakpoints, and adaptive UIs with complete TypeScript examples.

# 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

  1. The Problem with Raw Media Queries
  2. Basic useMediaQuery Implementation
  3. Advanced Hook Patterns
  4. Handling SSR and Hydration
  5. Practical Application Scenarios
  6. Performance Optimization Strategies
  7. FAQ

# The Problem with Raw Media Queries

Most developers handle responsive design purely in CSS:

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:

javascript
// ❌ 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:

typescript
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

javascript
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:

typescript
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

javascript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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):

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
const isMobile = useMediaQuery('(max-width: 768px)', {
  defaultValue: false, // Assume desktop on server
});

Or check if mounted:

typescript
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:

typescript
// 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:

typescript
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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ✅ GOOD: Respect motion preferences
const prefersMotion = useMediaQuery('(prefers-reduced-motion: no-preference)');
const animationClass = prefersMotion ? 'animate' : '';


# 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.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT