토글 버튼으로 테마 상태 변경(feat. context API, 커스텀 훅)

과거에 styled-components의 ThemeProvider로 다크모드를 구현하고 있었는데, setTheme 함수를 토글버튼에 직접 전달하다 보니 props 드릴링이 걱정되고 추상화가 잘 안되어서 코드가 지저분해 보여서 리팩토링을 하기로 했습니다.

테마의 상태를 관리하기 위해 평소에 global state 라이브러리를 많이 사용하는데 현재 개인 프로젝트에서 전역적으로 관리할 수 있는 상태는 테마뿐이라 따로 설치하지 않고 가볍게 사용할 수 있는 것을 찾아보았습니다.

패키지.

처음에는 URI 쿼리 문자열로 상태를 관리하는 방법을 생각했습니다.

그러나 사용자가 직접 URI를 수정할 수 있기 때문에 예상치 못한 변경이 발생할 수 있고, URI가 변경될 때마다 브라우저 기록이 불필요하게 누적될 수 있다는 단점이 있습니다.

그래서
다음 대안으로 고려하고 있던 컨텍스트 API를 사용하기로 했습니다.

리팩토링은 사용자 지정 후크를 사용하여 가능한 한 많이 논리를 모듈화하고 데이터를 추상화하려고 했습니다.

테마 상태 관리

const ThemeContext = createContext<ThemeContextType>({
  theme: THEME_KIND.LIGHT,
  setTheme: () => {},
});

export function ThemeContextProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const (theme, setTheme) = useState<ThemeKind>(THEME_KIND.LIGHT);

  const value = useMemo(() => ({ theme, setTheme }), (theme));

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

export const useThemeContext = () => useContext(ThemeContext);

테마 상태는 공급자에 배치되고 최상위 수준에서 관리됩니다.

가볍게 사용하려고 Context API를 선택했는데 오래 사용하다보니 로직을 다시 이해하는데 꽤 시간이 걸렸네요…

하나의 프로바이더만 사용하기 위해 값을 객체 형태로 만들었다.

결과적으로 개체의 주소 값이 렌더링될 때마다 변경되면 하위 구성 요소의 다시 렌더링도 발생할 수 있으므로 useMemo로 메모했습니다.

사실 컨텍스트에 의해 관리되는 상태는 하나뿐인데 왜 귀찮게 할까요? 싶지만 나중에 스테이터스가 오를 수 있기 때문에 이런 경우 미리 메모해 두는 것이 좋을 것 같습니다.

useModeTheme 사용자 정의 후크

function useModeTheme() {
  const { theme, setTheme } = useThemeContext();

  const { getItem: getStoredTheme, setItem: setStoredTheme } =
    useWebStorage<ThemeKind>({
      key: "theme",
      kind: STORAGE_KIND.LOCAL,
    });

  const handleChangeTheme = (nextTheme: ThemeKind) => {
    setStoredTheme(nextTheme);
    setTheme(nextTheme);
  };

  useEffect(() => {
    const storedTheme = getStoredTheme();

    if (storedTheme === THEME_KIND.DARK || storedTheme === THEME_KIND.LIGHT) {
      handleChangeTheme(storedTheme);
      return;
    }

    if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
      handleChangeTheme(THEME_KIND.DARK);
    }
  }, ());

  return { theme, handleChangeTheme };
}

export default useModeTheme;
  1. useThemeContext에서 테마 및 setTheme을 가져옵니다.

  2. 지정된 테마를 로컬 저장소에 저장하려면 useWebStorage 후크를 통해 모듈화된 기능을 가져옵니다.

  3. handleChangeTheme 함수를 생성하여 새로운 테마 정보를 인자로 전달하면 각각 setTheme와 setStoredTheme를 통해 context와 storage에 state가 반영된다.

  4. useEffect를 사용하면 초기 렌더링 중에 다음 작업이 실행됩니다.

    • 저장소에 저장된 상태가 있으면 가져와서 반영합니다.

    • 사용자 시스템에 설정된 테마가 다크모드일 경우 반영됩니다.

테마 전환 버튼

function ThemeToggle({ menuArr }: Props) {
  const { theme, handleChangeTheme } = useModeTheme();

  const { currentTab, highlight, handleBtnClick } = useThemeToggle({
    theme,
    handleChangeTheme,
  });

  return (
    <Style.TabContainer>
      <Style.Highlight left={highlight.left} width={highlight.width} />
      <Style.TabMenu>
        {menuArr.map((menu, index) => (
          <li
            key={`${index.toString()}-${menu}`}
            id={`${index}`}
            className={currentTab === index ? "focused" : ""}
            onClick={handleBtnClick}
            role="none"
          >
            {menu}
          </li>
        ))}
      </Style.TabMenu>
    </Style.TabContainer>
  );
}

export default ThemeToggle;
  1. useModeTheme 후크에서 테마 상태 및 handleChangeTheme 함수를 가져옵니다.

  2. 받은 것은 useThemeToggle hook에 전달되고 useThemeToggle은 토글에서 사용할 로직을 반환합니다.