기존에 styled-components의 ThemeProvider로 다크모드를 구현하고 있었는데, 토글 버튼에 setTheme 함수를 직접 전달해주다 보니 props drilling이 우려되고 추상화도 잘 되지 않아서 코드가 지저분해 보였기 때문에 리팩터링을 하려고 마음먹었다.
theme의 상태를 관리하기 위해서 보통 전역 상태 라이브러리를 많이 사용하지만, 현재 개인 프로젝트에서 전역으로 관리할 상태가 theme 밖에 없어서 패키지를 설치하지 않고 가볍게 사용할 수 있는 걸 찾으려고 했다.
처음엔 URI 쿼리 스트링으로 상태를 관리하는 방법을 생각했다. 하지만 유저가 URI를 직접 수정할 수 있어서 예기치 않은 변경이 발생할 수 있고, URI가 변경될 때마다 브라우저 히스토리가 쌓여서 히스토리가 불필요하게 많아질 수 있다는 단점이 있었다. 그래서 다음 대안으로 고려하던 context API를 사용하기로 했다.
리팩터링은 커스텀 훅을 사용해 최대한 로직을 모듈화 하고 데이터를 추상화해서 사용하려고 노력했다.
context API
const ThemeContext = createContext<ThemeContextType>({
theme: THEME_KIND.LIGHT,
setTheme: () => {},
});
export function ThemeContextProvider({
children,
}: Props) {
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);
theme 상태는 provider에 두고 최상위에서 관리한다.
가볍게 써보려고 context API를 선택했지만 오랜만에 쓰다 보니 로직을 다시 이해하는데 시간이 꽤 걸렸다...🥲
Provider를 하나만 쓰기 위해서 value를 객체 형태로 만들었다. 그러다 보니 렌더링 때마다 객체의 주소값이 바뀌면 하위 컴포넌트들도 리렌더링이 일어날 수 있어서 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;
- useThemeContext에서 theme과 setTheme을 가져와준다.
- 지정한 theme을 로컬스토리지에 저장하기 위해서 useWebStorage 훅을 통해 모듈화 해놓은 함수들을 가져온다.
- handleChangeTheme 함수를 만들어서 새로운 theme 정보가 인자로 넘어오면 setTheme과 setStoredTheme을 통해 각각 context와 스토리지에 상태를 반영해 준다.
- useEffect로 초기 렌더링시 작동해야 할 다음과 같은 동작들을 실행한다.
- 스토리지에 저장된 상태가 있으면 꺼내와서 반영
- 유저의 시스템에 설정되어 있는 theme이 dark mode면 반영
ThemeToggle 버튼
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;
- useModeTheme 훅으로부터 theme 상태와 handleChangeTheme 함수를 받는다.
- 받아온걸 그대로 useThemeToggle 훅으로 전달하고, useThemeToggle은 토글에서 사용할 로직을 반환한다.
App.tsx
이렇게 리팩터링 하고나니 App에서는 useModeTheme으로 theme 상태만 불러와서 ThemeProvider에 전달할 theme mode 객체를 골라주기만 하면 돼서 깔끔해졌다!
function App() {
const { theme } = useModeTheme();
return (
<ThemeProvider
theme={theme === THEME_KIND.LIGHT ? LIGHT_MODE_THEME : DARK_MODE_THEME}
>
<GlobalStyle />
<RouterProvider router={routers} />
</ThemeProvider>
);
}
후기
나름대로 추상화와 단일책임을 신경 쓰면서 리팩터링 했는데 확실히 컴포넌트 디자인은 끝도 없이 수정이 가능한 것 같다.
꾸준히 내가 작성한 코드를 씹고 뜯고 맛보고 즐기자🍗