React|Custom Hooks

useMediaQuery & useWindowSize: Controlling Responsive Design with JavaScript

4
useMediaQuery & useWindowSize: Controlling Responsive Design with JavaScript

Chris is building a responsive dashboard.

The designer has a specific requirement: "Please do not render the heavy sidebar component at all on mobile screens. Hiding it with display: none is a waste of performance."

CSS @media queries can control styles, but they cannot control React's Conditional Rendering.

In a rush, Chris wrote code using window.innerWidth.

typescript
// ❌ Chris's Half-Baked Responsiveness
function Dashboard() {
  // Checks only once when the component first mounts.
  const isMobile = window.innerWidth <= 768;

  return (
    <div>
      {!isMobile && <Sidebar />} {/* Render only when not mobile */}
      <MainContent />
    </div>
  );
}

It works fine on PC during testing. However, when he shrinks the browser window to a mobile size, the sidebar does not disappear.

This is because there is no logic (Event Listener) to tell React, "The window size changed, so redraw!"

Today, we will create two hooks that bring responsive logic into the JavaScript realm—useWindowSize and useMediaQuery—and even optimize their performance.

1. useWindowSize: Real-Time Pixel Tracking

The first approach is to subscribe to the browser's resize event and manage the width and height as state.

typescript
// useWindowSize.ts
import { useState, useEffect } from 'react';

export function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    // Update state whenever window size changes -> Triggers re-render
    window.addEventListener('resize', handleResize);
    
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return windowSize;
}

This hook is useful when drawing on a Canvas or when complex calculations based on aspect ratio are required.

However, using this just to know "Is it mobile?" incurs a huge performance cost. While the user resizes the window, handleResize executes dozens of times per second, triggering a re-render every single time.

2. useMediaQuery: Using CSS Syntax As Is

All we want is a Yes/No answer to the question, "Is it under 768px?" We don't need to know pixel-level changes.

For this, browsers provide a powerful API called window.matchMedia.

This API allows you to use CSS media query syntax directly and fires an event only when the condition changes (e.g., crossing from 769px to 768px). It is overwhelmingly superior in terms of performance.

Implementation: useMediaQuery

typescript
// useMediaQuery.ts
import { useState, useEffect } from 'react';

export function useMediaQuery(query: string): boolean {
  // For SSR: Default to false (or inject from outside)
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    // 1. Create media query matcher
    const media = window.matchMedia(query);
    
    // 2. Set initial value
    if (media.matches !== matches) {
      setMatches(media.matches);
    }

    // 3. Register event listener
    const listener = () => setMatches(media.matches);
    
    // Modern browsers: addEventListener, Older: addListener
    media.addEventListener('change', listener);
    
    return () => media.removeEventListener('change', listener);
  }, [query]);

  return matches;
}

Usage: Modifying Chris's Dashboard

Now you can control responsive logic in JavaScript just like writing CSS.

typescript
// Dashboard.tsx
function Dashboard() {
  // ✅ Instead of tracking every 1px, it renders only when crossing the threshold.
  const isMobile = useMediaQuery('(max-width: 768px)');
  const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');

  return (
    <div style={{ background: isDarkMode ? '#333' : '#fff' }}>
      {/* Now, simply shrinking the screen immediately Unmounts the sidebar. */}
      {!isMobile && <Sidebar />}
      <MainContent />
    </div>
  );
}

3. Deep Dive: SSR and Hydration Mismatch

Caution is needed when using these hooks in an SSR environment like Next.js.

Since the server has no window, it assumes isMobile is false and renders the HTML (including the sidebar).

However, if the client is a mobile device, isMobile becomes true, and React throws a Hydration Error saying, "The server and screen are different."

There are two standard ways to solve this:

  • Update State Only Inside useEffect: The first render matches the server value (false) unconditionally, and updates to the client value after mounting. (May cause flickering/FOUC).
  • Solve with CSS: If possible, consider hiding via CSS (display: none) first rather than blocking rendering. Use useMediaQuery only when the component is too heavy and rendering must be blocked.
  • 4. Which One Should I Use?

    HookUse Case
    useMediaQuery• Responsive layout placement (Mobile vs Desktop) • Detecting Dark Mode • Detecting Print Mode (This is the answer for most cases)
    useWindowSize• Dynamically adjusting <canvas> element size • Interactive webs needing to link scroll/mouse coordinates with window size

    Note: When using useWindowSize, you must apply Throttle or Debounce to limit the number of re-renders.

    Key Takeaways

  • Problem: Using window.innerWidth directly in the component body does not react when the window size changes.
  • useWindowSize: Tracks screen size in real-time by subscribing to the resize event. It has a high performance cost, so use it only when necessary.
  • useMediaQuery: Uses the window.matchMedia API to re-render only when CSS media query conditions change. It is the most efficient for responsive logic.
  • SSR Caution: Be mindful of initial value handling to prevent hydration errors due to screen size mismatches between server and client.

  • We learned how to handle the size of the screen. Now it's time to handle the "Visible Area" of the screen.

    Like Facebook or Instagram, we want Infinite Scroll where new posts appear as you scroll down.

    Let's create a hook that elegantly detects "Have we hit the bottom?" without complex scroll event calculations.

    Continuing in: “useIntersectionObserver: The Core of Infinite Scroll and Lazy Loading”

    🔗 References

  • MDN - Window.matchMedia
  • usehooks-ts: useMediaQuery
  • usehooks-ts: useWindowSize
  • Comments (0)

    0/1000 characters
    Loading comments...
    useMediaQuery & useWindowSize: Controlling Responsive Design with JavaScript | VXD Blog