Skip to the code
cf.

useMediaQuery

Write useMediaQuery(query) and export it from useMediaQuery.js. It returns true while a CSS media query matches, like "(min-width: 768px)" for the tea shop's two-column layout, and false while it doesn't.

window.matchMedia(query) returns a MediaQueryList. Its matches says whether the query matches now, and it fires a change event when that changes. That makes it an external store, so read it with useSyncExternalStore: subscribe with addEventListener("change", …), and remove that listener in the unsubscribe function.

The answer is right on the very first render in the browser, with no flash of the wrong layout. When query changes, answer the new query and stop listening to the old one. Stop listening when the component goes away.

On the server there's no matchMedia, so the server snapshot is false. When React hydrates HTML that came from the server, its first render uses false too, then it switches to the real answer.

function Menu() {
  const wide = useMediaQuery("(min-width: 768px)");
  return <p>{wide ? "Two columns" : "One column"}</p>;
}

The Preview shows Demo.jsx, which uses your hook. Make the Preview wider and narrower to see it change.

What the tests check

  1. returns false when the query doesn't match
    mockMatchMedia(false);
    const { result } = renderHook(() => useMediaQuery("(min-width: 768px)"));
    expect(result.current).toBe(false);
    
  2. returns true when it matches, for the query it's given
    const media = mockMatchMedia((query) => query === "(min-width: 768px)");
    const { result } = renderHook(() => useMediaQuery("(min-width: 768px)"));
    expect(result.current).toBe(true);
    expect(media.queries()).toContain("(min-width: 768px)");
    
  3. is right on the very first render
    mockMatchMedia(true);
    const seen = [];
    function Menu() {
      const wide = useMediaQuery("(min-width: 768px)");
      seen.push(wide);
      return <p>{wide ? "Two columns" : "One column"}</p>;
    }
    render(<Menu />);
    expect(seen[0]).toBe(true);
    
  4. updates when the answer changes
    const media = mockMatchMedia(false);
    const { result } = renderHook(() => useMediaQuery("(min-width: 768px)"));
    await media.set(true);
    expect(result.current).toBe(true);
    await media.set(false);
    expect(result.current).toBe(false);
    
  5. in a component, switches the layout
    const media = mockMatchMedia(false);
    function Menu() {
      const wide = useMediaQuery("(min-width: 768px)");
      return <p>{wide ? "Two columns" : "One column"}</p>;
    }
    render(<Menu />);
    expect(screen.getByText("One column")).toBeInTheDocument();
    await media.set(true);
    expect(screen.getByText("Two columns")).toBeInTheDocument();
    
  6. answers a new query when query changes
    const media = mockMatchMedia((query) => query === "(min-width: 1024px)");
    const { result, rerender } = renderHook(({ query }) => useMediaQuery(query), { initialProps: { query: "(min-width: 768px)" } });
    expect(result.current).toBe(false);
    rerender({ query: "(min-width: 1024px)" });
    expect(result.current).toBe(true);
    await media.set(false);
    expect(result.current).toBe(false);
    
  7. moves its listener to the new query, and removes it when it goes away
    mockMatchMedia(false);
    const make = window.matchMedia;
    const active = [];
    window.matchMedia = (query) => {
      const list = make(query);
      const add = list.addEventListener;
      const remove = list.removeEventListener;
      list.addEventListener = (type, listener) => {
        if (type === "change") active.push({ list, listener });
        add(type, listener);
      };
      list.removeEventListener = (type, listener) => {
        const at = active.findIndex((entry) => entry.list === list && entry.listener === listener);
        if (type === "change" && at !== -1) active.splice(at, 1);
        remove(type, listener);
      };
      return list;
    };
    const listening = (query) => active.filter((entry) => entry.list.media === query).length;
    const { rerender, unmount } = renderHook(({ query }) => useMediaQuery(query), { initialProps: { query: "(min-width: 768px)" } });
    expect(listening("(min-width: 768px)")).toBe(1);
    rerender({ query: "(min-width: 1024px)" });
    expect(listening("(min-width: 768px)")).toBe(0);
    expect(listening("(min-width: 1024px)")).toBe(1);
    unmount();
    expect(listening("(min-width: 1024px)")).toBe(0);
    
  8. hydrates with false, the server snapshot, then switches
    mockMatchMedia(true);
    const seen = [];
    function Menu() {
      const wide = useMediaQuery("(min-width: 768px)");
      seen.push(wide);
      return <p>{wide ? "Two columns" : "One column"}</p>;
    }
    const container = document.createElement("div");
    container.innerHTML = "<p>One column</p>";
    document.body.append(container);
    render(<Menu />, { container, hydrate: true });
    expect(seen[0]).toBe(false);
    expect(await screen.findByText("Two columns")).toBeInTheDocument();
    

Learn more

useMediaQuery.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).