Next.js/zustand - SSR Hydration 에러 (Text content does not match server-rendered HTML)

Shyuuuuni·2023년 4월 30일
2

❌ Trouble-shooting

목록 보기
6/9

문제상황

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

에러 메세지에서 나타난 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 };
}

결과

오류 없이 다크모드를 전역 상태로 관리할 수 있었다.

다크모드

일반모드

profile
배짱개미 개발자 김승현입니다 🖐

1개의 댓글

comment-user-thumbnail
2023년 6월 8일

SSR은 한번더 고민해야겠는걸요

답글 달기