Goodbye useEffect

Media queries

If you useState with useEffect to subscribe to events you have to worry about dependency arrays:


import { useState, useEffect } from "react";

export function useMediaQuery(query: string) {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const media = window.matchMedia(query);
    if (media.matches !== matches) {
      setMatches(media.matches);
    }
    const listener = () => setMatches(media.matches);
    window.addEventListener("resize", listener);
    return () => window.removeEventListener("resize", listener);
  }, [matches, query]);

  return matches;
}

Instead try useSyncExternalStore. Your breakpoints are very likely constant, so create custom hooks for each one.

import { useSyncExternalStore } from "react";

function makeUseMediaQuery(mediaQuery: string, getServerValue: () => boolean): () => boolean {
  function subscribe(onStoreChange) {
    const aborter = new AbortController();
    // https://css-tricks.com/working-with-javascript-media-queries/
    // All browsers support addEventListener instead of addListener today.
    // https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/change_event
    window.matchMedia(mediaQuery).addEventListener("change", onStoreChange, { signal: aborter.signal });
    return () => {
      aborter.abort();
    };
  }

  return function useCustomMediaQuery() {
    return useSyncExternalStore(
      subscribe,
      () => window.matchMedia(mediaQuery).matches,
      getServerValue
    );
  };
}

// Default Tailwind breakpoints: https://tailwindcss.com/docs/responsive-design
// Server rendering is mobile-first for SEO so we default to false.
export const useSm = makeUseMediaQuery("(min-width: 640px)", () => false);
export const useMd = makeUseMediaQuery("(min-width: 768px)", () => false);
export const useLg = makeUseMediaQuery("(min-width: 1024px)", () => false);

// Derive “is mobile” from whether sm breakpoint matches.
export function useIsMobile(): boolean {
  return !useSm();
}