Next.js SSR 환경에서 테마(다크모드)를 전역 상태로 관리하기 위해 zustand 라이브러리를 함께 사용하려고 시도했다.
기존에 CSR 환경에서 zustand persist 미들웨어를 이용해 로컬 스토리지에 전역 상태를 유지해본 경험이 있어서, 같은 방법으로 시도했다.
적용 후 테스트를 진행했는데, 다크모드가 아닌 경우(isDark = false
)는 오류가 발생하지 않았지만, 다크모드인 경우(isDark = true
)는 해당 오류가 발생했다.
zustand store 선언부분
// @/stores/useThemeStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type Persist<T> = (
config: StateCreator<T>,
options: PersistOptions<T>
) => StateCreator<T>;
export type ThemeStore = {
isDark: boolean;
setDark: (isDark: boolean) => void;
};
export const INITIAL_THEME_STORE: ThemeStore = {
isDark: false,
setDark: (isDark: boolean) => ({}),
};
export const usePersistedThemeStore = create<ThemeStore>(
(persist as Persist<ThemeStore>)(
(set) => ({
...INITIAL_THEME_STORE,
isDark: false,
setDark: (isDark: boolean) => set(() => ({ isDark })),
}),
{ name: "theme-storage" }
)
);
store를 참고하여 다크모드를 조작하는 커스텀 훅
// @/hooks/useDarkMode.ts
import { useEffect } from "react";
import { usePersistedThemeStore } from "@/stores/themeStore";
export default function useDarkMode() {
const [isDark, setDark] = usePersistedThemeStore((state) => [
state.isDark,
state.setDark,
]);
const toggleDark = () => {
setDark(!isDark);
};
// tailwindcss 의 class 기반 다크모드 적용 처리
useEffect(() => {
// 다크모드 전역 상태에 따라 <html> 태그에 "dark" class 적용 결정
if (typeof window !== "undefined") {
document.documentElement.classList.toggle("dark", isDark);
}
}, [isDark]);
return { isDark, setDark, toggleDark };
}
에러 메세지에서 나타난 Text content does not match server-rendered HTML
오류는 SSR 환경에서 서버에서 pre-rendering 한 HTML 데이터가 클라이언트에서 rendering 한 HTML 데이터와 일치하지 않을 때 발생한다고 한다.
이번 프로젝트의 경우, 로컬 스토리지에 다크모드 속성이 적용된 경우 { isDark: true } 라는 전역 상태로 초기화되어 사용된다. 하지만 서버사이드에서는 persist 된 상태가 없으므로 { isDark: false } 라는 기본 상태로 초기화되어 사용된다.
따라서 서버에서 pre-rendering 한 HTML에는 true라는 값이, 클라이언트에서 rendering 한 HTML에는 false라는 값이 사용되었기 때문에 위 문제가 발생했다고 판단했다.
zustand의 issues에서 실마리를 찾을 수 있었다. (관련 이슈: https://github.com/pmndrs/zustand/issues/1145)
아직까지 라이브러리에서 공식적으로 이를 깔끔하게 해결할 수 있는 방법은 제공하지 않는 것 같고, 이슈에 달린 답변을 참고해서 해결할 수 있었다.
이슈에서 가장 추천하는 방법은 아래와 같이 hydrated 여부를 확인하고, hydrated 상태에 따라 적절히 반환값을 바꿔주는 방식이다. (혹은 hydrated 상태가 아니라면 컴포넌트 대신 <div>Loading..</div>
와 같은 로딩 화면을 보여주는 방식도 있다고 한다.)
// This a fix to ensure zustand never hydrates the store before React hydrates the page
// else it causes a mismatch between SSR/SSG and client side on first draw which produces an error
export const useStore = ((selector, compare) => {
const store = usePersistedStore(selector, compare)
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
return hydrated ? store : selector(emptyState)
}) as typeof usePersistedStore
위 훅은 hydrated라는 상태를 통해 useEffect 훅 호출 이전에는 스토어의 초기값을 반환하고, useEffect 훅 호출 이후에 zustand 스토어의 hydration을 실행시키는 역할을 담당한다.
즉, SSR의 결과값이 클라이언트에서 Hydration 된 후에 zustand persist 스토어가 hydration 되는 것을 보장한다. 이를 통해 Text content does not match server-rendered HTML
가 발생하는 원인을 막을 수 있다고 한다.
기존 useThemeStore 는 그대로 사용했다.
// @/stores/useThemeStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type Persist<T> = (
config: StateCreator<T>,
options: PersistOptions<T>
) => StateCreator<T>;
export type ThemeStore = {
isDark: boolean;
setDark: (isDark: boolean) => void;
};
export const INITIAL_THEME_STORE: ThemeStore = {
isDark: false,
setDark: (isDark: boolean) => ({}),
};
export const usePersistedThemeStore = create<ThemeStore>(
(persist as Persist<ThemeStore>)(
(set) => ({
...INITIAL_THEME_STORE,
isDark: false,
setDark: (isDark: boolean) => set(() => ({ isDark })),
}),
{ name: "theme-storage" }
)
);
그리고 usePersistedStore는 해결 방법을 보고 새로 추가한 커스텀 훅이다.
github 이슈에는 하나의 스토어에서 사용하는 예시를 보여줬는데, 다른 persist store에서도 활용할 수 있도록 조금 더 추상화해서 구현했다.
훅의 파라미터로 호출할 zustand 스토어의 훅과 초기 상태, 그리고 스토어에서 검색하기 위한 selector를 입력받는다.
그리고 앞서 설명한 개념을 이용해 useEffect 를 통해 hydration 문제를 해결했다.
// @/hooks/usePersistedStore.ts
import { useEffect, useState } from "react";
import { StoreApi, UseBoundStore } from "zustand";
/**
* SSR 환경에서 PersistedStore를 사용할 수 있도록 지원하는 커스텀 훅
* PersistedStore를 직접 사용할 경우, Text content does not match server-rendered HTML 오류가 발생할 수 있다.
* 이를 방지하기 위해 useEffect 호출 이후에 PersistedStore를 동기화해서 SSR의 결과가 클라이언트에서 마크업되는 것을 보장할 수 있다.
* @param useStore 대상 zustand 스토어를 호출하는 훅
* @param initialStore 대상 zustand 스토어의 초기값
* @param selector 스토어의 selector
* @returns 스토어에서 selector를 실행한 결과
*/
export default function usePersistedStore<T, V>(
useStore: UseBoundStore<StoreApi<T>>,
initialStore: T,
selector: (store: T) => V
) {
const [hydrated, setHydrated] = useState(false);
const storeState = useStore(selector);
useEffect(() => {
setHydrated(true);
}, []);
return hydrated ? storeState : selector(initialStore);
}
그리고 호출하는 부분에서는 아래와 같이 호출할 수 있다.
// @/hooks/useDarkMode.ts
import { useEffect } from "react";
import usePersistedStore from "@/hooks/usePersistedStore";
import {
INITIAL_THEME_STORE,
ThemeStore,
usePersistedThemeStore,
} from "@/stores/themeStore";
export default function useDarkMode() {
const selector = (store: ThemeStore): [boolean, (arg: boolean) => void] => [
store.isDark,
store.setDark,
];
const [isDark, setDark] = usePersistedStore(
usePersistedThemeStore,
INITIAL_THEME_STORE,
selector
);
const toggleDark = () => {
setDark(!isDark);
};
// tailwindcss 의 class 기반 다크모드 적용 처리
useEffect(() => {
// 다크모드 전역 상태에 따라 <html> 태그에 "dark" class 적용 결정
if (typeof window !== "undefined") {
document.documentElement.classList.toggle("dark", isDark);
}
}, [isDark]);
return { isDark, setDark, toggleDark };
}
오류 없이 다크모드를 전역 상태로 관리할 수 있었다.
다크모드
일반모드
SSR은 한번더 고민해야겠는걸요