Skip to content
Go back

useMediaQuery 훅 구현하기: useEffect에서 useSyncExternalStore로

Edit page

들어가며

반응형 웹 개발을 하다 보면 미디어 쿼리를 React 컴포넌트에서 사용해야 할 때가 많습니다. 이번 글에서는 useMediaQuery 커스텀 훅을 구현하면서 겪은 시행착오와, React 공식 문서를 통해 배운 더 나은 접근 방식을 공유합니다.

첫 번째 시도: useEffect + useState

처음에는 가장 익숙한 방식으로 접근했습니다. useEffect로 미디어 쿼리를 구독하고, useState로 상태를 관리하는 방식이었죠.

import { useState, useEffect } from "react";

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState<boolean>(() => {
    if (typeof window !== "undefined") {
      return window.matchMedia(query).matches;
    }
    return false;
  });

  useEffect(() => {
    if (typeof window === "undefined") {
      return;
    }

    const mediaQuery = window.matchMedia(query);

    // 초기값 설정
    setMatches(mediaQuery.matches);

    // 이벤트 리스너
    const handleChange = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    mediaQuery.addEventListener("change", handleChange);

    return () => {
      mediaQuery.removeEventListener("change", handleChange);
    };
  }, [query]);

  return matches;
}

언뜻 보기엔 문제없어 보였습니다. SSR도 고려했고, 이벤트 리스너도 제대로 정리했으니까요.

문제 발견: ESLint 경고

하지만 ESLint에서 경고가 나타났습니다:

Error: Calling setState synchronously within an effect can trigger cascading renders

Effects are intended to synchronize state between React and external systems...
Calling setState synchronously within an effect body causes cascading renders
that can hurt performance, and is not recommended.

useEffect 내부에서 setMatches(mediaQuery.matches)를 동기적으로 호출하는 것이 문제였습니다. 이는 cascading renders를 유발할 수 있고, 성능에 좋지 않다는 것이죠.

React 공식 문서에서 찾은 답

경고 메시지에 포함된 링크(You Might Not Need an Effect)를 따라가니, 제가 겪은 상황에 대한 설명이 있었습니다.

공식 문서는 이렇게 말합니다:

“Sometimes, your components may need to subscribe to some data outside of the React state. This data could be from a third-party library or a built-in browser API. Since this data can change without React’s knowledge, you need to manually subscribe your components to it.”

그리고 더 나은 해결책을 제시합니다: useSyncExternalStore

올바른 해결책: useSyncExternalStore

React 18에서 도입된 useSyncExternalStore는 외부 스토어를 구독하기 위한 공식 훅입니다. 미디어 쿼리도 React 외부의 브라우저 API이므로, 이 훅을 사용하는 것이 적합합니다.

import { useSyncExternalStore } from "react";

function useMediaQuery(query: string): boolean {
  const subscribe = (callback: () => void) => {
    const mediaQuery = window.matchMedia(query);
    mediaQuery.addEventListener("change", callback);
    return () => {
      mediaQuery.removeEventListener("change", callback);
    };
  };

  const getSnapshot = () => {
    return window.matchMedia(query).matches;
  };

  const getServerSnapshot = () => {
    return false;
  };

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

export default useMediaQuery;

useSyncExternalStore의 구조

이 훅은 세 개의 인자를 받습니다:

1. subscribe 함수

외부 스토어의 변경사항을 구독합니다. 콜백을 받아서 이벤트 리스너를 등록하고, cleanup 함수를 반환합니다.

const subscribe = (callback: () => void) => {
  const mediaQuery = window.matchMedia(query);
  mediaQuery.addEventListener("change", callback);
  return () => mediaQuery.removeEventListener("change", callback);
};

2. getSnapshot 함수

클라이언트에서 현재 값을 동기적으로 읽습니다.

const getSnapshot = () => window.matchMedia(query).matches;

3. getServerSnapshot 함수

서버 사이드 렌더링을 위한 기본값을 제공합니다.

const getServerSnapshot = () => false;

사용 예시

구현한 훅은 이렇게 사용할 수 있습니다:

function MyComponent() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
  const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');

  return (
    <div>
      {isMobile && <MobileLayout />}
      {isTablet && <TabletLayout />}
      {isDarkMode && <DarkTheme />}
    </div>
  );
}

결론

처음에는 익숙한 useEffect로 시작했지만, ESLint 경고를 통해 더 나은 방법을 배울 수 있었습니다. React 공식 문서는 이런 상황을 정확히 설명하고 있었고, useSyncExternalStore가 외부 시스템 구독을 위한 올바른 도구라는 것을 알게 되었습니다.

미디어 쿼리뿐만 아니라 브라우저 API나 외부 라이브러리를 구독해야 할 때도 같은 패턴을 적용할 수 있습니다. navigator.onLine, window.localStorage, 또는 서드파티 상태 관리 라이브러리 등 React 외부의 변경 가능한 데이터를 다룰 때는 useSyncExternalStore를 먼저 고려해보세요.

참고 자료


Edit page
Previous Post
더 이상 isModalOpen을 쓰고 싶지 않아요
Next Post
리액트에서 외부 스크립트 로드하기