T-CREATOR

Zustand × Next.js の Hydration Mismatch を根絶する:原因別チェックリスト

Zustand × Next.js の Hydration Mismatch を根絶する:原因別チェックリスト

Next.js と Zustand を組み合わせた開発で、突然現れる「Hydration Mismatch」エラー。 このエラーは開発者を悩ませる厄介な問題ですが、適切な診断と対処法を知っていれば確実に解決できます。

本記事では、Hydration Mismatch の原因を体系的に分類し、それぞれに対する具体的な解決手順をチェックリスト形式でご紹介します。 実際のプロジェクトで遭遇しやすいパターン別に整理することで、効率的な問題解決をサポートいたします。

背景

Hydration Mismatch とは

Hydration Mismatch は、サーバーサイドレンダリング(SSR)で生成された HTML と、クライアントサイドで React が期待する DOM 構造が一致しない場合に発生するエラーです。

Next.js では、初回レンダリング時にサーバーで HTML を生成し、その後クライアントサイドで React が引き継ぎます。この引き継ぎ処理を「Hydration」と呼びますが、両者の状態が異なると不整合が生じてしまいます。

mermaidflowchart LR
  server[サーバー] -->|HTML生成| html[初期HTML]
  html -->|ブラウザ表示| client[クライアント]
  client -->|React引き継ぎ| hydration[Hydration]
  hydration -->|状態不一致| error[Mismatch Error]
  hydration -->|状態一致| success[正常動作]

図で理解できる要点:

  • サーバーとクライアントで同じ状態を維持する必要がある
  • Hydration 時点での不整合がエラーの原因となる
  • 状態管理ライブラリの初期化タイミングが重要

Zustand と Next.js の組み合わせで発生する問題

Zustand は軽量で使いやすい状態管理ライブラリですが、Next.js との組み合わせで特有の課題があります。

主な問題要因は以下の通りです:

問題要因詳細
状態の初期化タイミングサーバーとクライアントで異なるタイミングで状態が初期化される
ブラウザ API への依存localStorage などのブラウザ専用 API がサーバーサイドで実行できない
非同期処理の差異useEffect の実行タイミングがサーバーとクライアントで異なる

これらの要因により、サーバーで生成された初期状態とクライアントで期待される状態に差が生じ、Hydration Mismatch が発生してしまいます。

課題

Hydration Mismatch が発生する主要な原因

Zustand を使用したプロジェクトで Hydration Mismatch が発生する原因を、発生頻度の高い順に整理いたします。

mermaidflowchart TD
  start[Hydration Mismatch発生] --> check1{localStorage使用?}
  check1 -->|Yes| cause1[ブラウザAPI依存問題]
  check1 -->|No| check2{初期状態設定?}
  check2 -->|あり| cause2[初期状態不整合]
  check2 -->|なし| check3{useEffect使用?}
  check3 -->|Yes| cause3[タイミング問題]
  check3 -->|No| cause4[状態同期問題]

原因別の発生頻度と影響度:

順位原因発生頻度影響度解決難易度
1localStorage/sessionStorage 問題
2初期状態の不整合問題
3useEffect タイミング問題
4サーバー・クライアント状態同期問題

一般的なエラーパターンと症状

実際に発生するエラーメッセージとその症状を確認しましょう。

典型的なエラーメッセージ:

typescript// よく見かけるエラーメッセージ
Warning: Text content did not match. Server: "" Client: "ユーザー名"

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Warning: Expected server HTML to contain a matching <div> in <div>.

症状の判別方法:

typescript// 開発者ツールで確認できる症状
console.error('[Hydration Error] Server:', serverState);
console.error('[Hydration Error] Client:', clientState);

// 典型的な症状パターン
// 1. 初回レンダリング時のみエラーが発生
// 2. リロード時に毎回同じエラーが表示
// 3. 特定のページでのみ発生

これらの症状を正確に把握することで、適切な解決策を選択できるようになります。

解決策

サーバーサイドとクライアントサイドの状態同期問題

この問題は、サーバーとクライアントで異なる初期状態が設定されることで発生します。

原因の特定方法

1. デバッグログの追加

typescript// store.ts - デバッグ用のログを追加
import { create } from 'zustand';

interface UserState {
  user: string | null;
  setUser: (user: string) => void;
}

export const useUserStore = create<UserState>((set) => {
  // サーバー・クライアント判定
  const isServer = typeof window === 'undefined';

  console.log(
    `Store初期化: ${isServer ? 'Server' : 'Client'}`
  );

  return {
    user: null,
    setUser: (user) => set({ user }),
  };
});

2. Hydration 状態の確認

typescript// components/UserDisplay.tsx - Hydration状態を確認
import { useEffect, useState } from 'react';
import { useUserStore } from '../store';

export const UserDisplay = () => {
  const [isHydrated, setIsHydrated] = useState(false);
  const user = useUserStore((state) => state.user);

  useEffect(() => {
    setIsHydrated(true);
    console.log('Hydration完了:', { user });
  }, [user]);

  // Hydration前は何も表示しない
  if (!isHydrated) {
    return null;
  }

  return <div>ユーザー: {user || '未ログイン'}</div>;
};

解決手順チェックリスト

☐ Step 1: 状態の初期化タイミングを統一する

typescript// store.ts - 統一された初期化
import { create } from 'zustand';

interface AppState {
  isHydrated: boolean;
  user: string | null;
  setHydrated: () => void;
  setUser: (user: string) => void;
}

export const useAppStore = create<AppState>((set) => ({
  isHydrated: false,
  user: null,
  setHydrated: () => set({ isHydrated: true }),
  setUser: (user) => set({ user }),
}));

☐ Step 2: Hydration ガードの実装

typescript// hooks/useHydration.ts - カスタムフック
import { useEffect } from 'react';
import { useAppStore } from '../store';

export const useHydration = () => {
  const { isHydrated, setHydrated } = useAppStore();

  useEffect(() => {
    if (!isHydrated) {
      setHydrated();
    }
  }, [isHydrated, setHydrated]);

  return isHydrated;
};

☐ Step 3: コンポーネントでの適用

typescript// components/SafeComponent.tsx - 安全なコンポーネント
import { useHydration } from '../hooks/useHydration';
import { useAppStore } from '../store';

export const SafeComponent = () => {
  const isHydrated = useHydration();
  const user = useAppStore((state) => state.user);

  if (!isHydrated) {
    return <div>読み込み中...</div>;
  }

  return <div>ユーザー: {user || '未ログイン'}</div>;
};

初期状態の不整合問題

初期状態が動的に決まる場合や、環境によって異なる値が設定される場合に発生します。

原因の特定方法

1. 初期状態の追跡

typescript// store.ts - 初期状態をログで追跡
import { create } from 'zustand';

const getInitialTheme = () => {
  // 問題のあるパターン
  if (typeof window !== 'undefined') {
    return window.localStorage.getItem('theme') || 'light';
  }
  return 'light'; // サーバーでは常にlight
};

interface ThemeState {
  theme: string;
  setTheme: (theme: string) => void;
}

export const useThemeStore = create<ThemeState>((set) => {
  const initialTheme = getInitialTheme();
  console.log('初期テーマ:', initialTheme);

  return {
    theme: initialTheme,
    setTheme: (theme) => set({ theme }),
  };
});

2. レンダリング結果の比較

typescript// components/ThemeProvider.tsx - レンダリング結果を比較
import { useEffect, useState } from 'react';
import { useThemeStore } from '../store';

export const ThemeProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [mounted, setMounted] = useState(false);
  const theme = useThemeStore((state) => state.theme);

  useEffect(() => {
    setMounted(true);
    console.log('マウント後テーマ:', theme);
  }, [theme]);

  // マウント前は中立な表示
  if (!mounted) {
    return <div className='theme-loading'>{children}</div>;
  }

  return <div className={`theme-${theme}`}>{children}</div>;
};

解決手順チェックリスト

☐ Step 1: 初期状態を一定にする

typescript// store.ts - 安全な初期状態設定
import { create } from 'zustand';

interface ThemeState {
  theme: string;
  isThemeLoaded: boolean;
  setTheme: (theme: string) => void;
  loadTheme: () => void;
}

export const useThemeStore = create<ThemeState>(
  (set, get) => ({
    theme: 'light', // 常に同じ初期値
    isThemeLoaded: false,
    setTheme: (theme) => {
      set({ theme });
      if (typeof window !== 'undefined') {
        localStorage.setItem('theme', theme);
      }
    },
    loadTheme: () => {
      if (typeof window !== 'undefined') {
        const savedTheme = localStorage.getItem('theme');
        if (savedTheme) {
          set({ theme: savedTheme, isThemeLoaded: true });
        } else {
          set({ isThemeLoaded: true });
        }
      }
    },
  })
);

☐ Step 2: マウント後の状態復元

typescript// hooks/useThemeLoader.ts - テーマ読み込みフック
import { useEffect } from 'react';
import { useThemeStore } from '../store';

export const useThemeLoader = () => {
  const { loadTheme, isThemeLoaded } = useThemeStore();

  useEffect(() => {
    if (!isThemeLoaded) {
      loadTheme();
    }
  }, [loadTheme, isThemeLoaded]);

  return isThemeLoaded;
};

☐ Step 3: アプリケーション全体での適用

typescript// pages/_app.tsx - アプリ全体での適用
import { useThemeLoader } from '../hooks/useThemeLoader';
import { useThemeStore } from '../store';

function MyApp({ Component, pageProps }: AppProps) {
  const isThemeLoaded = useThemeLoader();
  const theme = useThemeStore((state) => state.theme);

  return (
    <div
      className={
        isThemeLoaded ? `theme-${theme}` : 'theme-loading'
      }
    >
      <Component {...pageProps} />
    </div>
  );
}

export default MyApp;

useEffect タイミング問題

useEffect の実行タイミングがサーバーとクライアントで異なることによる問題です。

原因の特定方法

1. useEffect の実行順序確認

typescript// components/EffectTiming.tsx - 実行順序を確認
import { useEffect, useState } from 'react';
import { useUserStore } from '../store';

export const EffectTiming = () => {
  const [renderCount, setRenderCount] = useState(0);
  const user = useUserStore((state) => state.user);
  const setUser = useUserStore((state) => state.setUser);

  console.log(`レンダー ${renderCount}: user = ${user}`);

  useEffect(() => {
    console.log('useEffect実行:', { user, renderCount });
    setRenderCount((prev) => prev + 1);
  }, [user]);

  useEffect(() => {
    // 問題のあるパターン:即座に状態を変更
    if (!user) {
      setUser('デフォルトユーザー');
    }
  }, [user, setUser]);

  return <div>現在のユーザー: {user}</div>;
};

2. 副作用の依存関係分析

typescript// hooks/useUserEffect.ts - 副作用を分離
import { useEffect, useRef } from 'react';
import { useUserStore } from '../store';

export const useUserEffect = () => {
  const user = useUserStore((state) => state.user);
  const setUser = useUserStore((state) => state.setUser);
  const hasInitialized = useRef(false);

  useEffect(() => {
    console.log('useUserEffect:', {
      user,
      hasInitialized: hasInitialized.current,
      isClient: typeof window !== 'undefined',
    });

    if (
      !hasInitialized.current &&
      typeof window !== 'undefined'
    ) {
      hasInitialized.current = true;
      // 初期化処理
    }
  }, [user, setUser]);

  return { user, isInitialized: hasInitialized.current };
};

解決手順チェックリスト

☐ Step 1: useEffect の実行条件を明確化

typescript// hooks/useSafeEffect.ts - 安全なuseEffectラッパー
import { useEffect, useRef } from 'react';

export const useSafeEffect = (
  effect: () => void | (() => void),
  deps: React.DependencyList,
  skipServer = true
) => {
  const hasRun = useRef(false);

  useEffect(() => {
    // サーバーサイドでの実行をスキップ
    if (skipServer && typeof window === 'undefined') {
      return;
    }

    // 初回実行の制御
    if (!hasRun.current) {
      hasRun.current = true;
      return effect();
    }

    return effect();
  }, deps);
};

☐ Step 2: 状態変更のタイミング制御

typescript// store.ts - タイミング制御付きストア
import { create } from 'zustand';

interface TimingControlledState {
  user: string | null;
  isReady: boolean;
  setUser: (user: string) => void;
  setReady: () => void;
  initializeUser: () => void;
}

export const useTimingStore = create<TimingControlledState>(
  (set, get) => ({
    user: null,
    isReady: false,
    setUser: (user) => set({ user }),
    setReady: () => set({ isReady: true }),
    initializeUser: () => {
      const { isReady } = get();
      if (isReady && typeof window !== 'undefined') {
        // 安全なタイミングでの初期化
        const savedUser = localStorage.getItem('user');
        if (savedUser) {
          set({ user: savedUser });
        }
      }
    },
  })
);

☐ Step 3: コンポーネントでの適切な使用

typescript// components/TimingAwareComponent.tsx - タイミングを考慮したコンポーネント
import { useEffect } from 'react';
import { useSafeEffect } from '../hooks/useSafeEffect';
import { useTimingStore } from '../store';

export const TimingAwareComponent = () => {
  const { user, isReady, setReady, initializeUser } =
    useTimingStore();

  // マウント時の準備
  useEffect(() => {
    if (!isReady) {
      setReady();
    }
  }, [isReady, setReady]);

  // 安全なタイミングでの初期化
  useSafeEffect(() => {
    if (isReady) {
      initializeUser();
    }
  }, [isReady, initializeUser]);

  if (!isReady) {
    return <div>準備中...</div>;
  }

  return <div>ユーザー: {user || '未設定'}</div>;
};

localStorage/sessionStorage 問題

ブラウザ専用の API を使用することで発生する最も一般的な問題です。

原因の特定方法

1. ブラウザ API の使用箇所特定

typescript// utils/storageCheck.ts - ストレージ使用箇所の特定
export const checkStorageUsage = () => {
  const storageUsage = {
    localStorage: [],
    sessionStorage: [],
    cookies: [],
  };

  // 使用箇所を検索するためのヘルパー
  const originalLocalStorage = window.localStorage;
  const originalSessionStorage = window.sessionStorage;

  window.localStorage = new Proxy(originalLocalStorage, {
    get(target, prop) {
      console.trace('localStorage accessed:', prop);
      return target[prop];
    },
  });

  return storageUsage;
};

2. SSR/CSR での動作確認

typescript// components/StorageDebugger.tsx - ストレージ動作の確認
import { useEffect, useState } from 'react';

export const StorageDebugger = () => {
  const [storageInfo, setStorageInfo] = useState({
    isServer: typeof window === 'undefined',
    hasLocalStorage: false,
    localStorageValue: null,
  });

  useEffect(() => {
    setStorageInfo({
      isServer: false,
      hasLocalStorage: typeof localStorage !== 'undefined',
      localStorageValue: localStorage.getItem('debug-key'),
    });
  }, []);

  return (
    <div>
      <h3>ストレージデバッグ情報</h3>
      <pre>{JSON.stringify(storageInfo, null, 2)}</pre>
    </div>
  );
};

解決手順チェックリスト

☐ Step 1: 安全なストレージアクセス関数の作成

typescript// utils/safeStorage.ts - 安全なストレージアクセス
export const safeStorage = {
  getItem: (key: string): string | null => {
    if (typeof window === 'undefined') {
      return null;
    }
    try {
      return localStorage.getItem(key);
    } catch (error) {
      console.warn('localStorage access failed:', error);
      return null;
    }
  },

  setItem: (key: string, value: string): boolean => {
    if (typeof window === 'undefined') {
      return false;
    }
    try {
      localStorage.setItem(key, value);
      return true;
    } catch (error) {
      console.warn('localStorage write failed:', error);
      return false;
    }
  },

  removeItem: (key: string): boolean => {
    if (typeof window === 'undefined') {
      return false;
    }
    try {
      localStorage.removeItem(key);
      return true;
    } catch (error) {
      console.warn('localStorage remove failed:', error);
      return false;
    }
  },
};

☐ Step 2: ストレージ対応の Zustand ストア作成

typescript// store/storageStore.ts - ストレージ対応ストア
import { create } from 'zustand';
import { safeStorage } from '../utils/safeStorage';

interface StorageState {
  data: any;
  isLoaded: boolean;
  setData: (data: any) => void;
  loadFromStorage: (key: string) => void;
  saveToStorage: (key: string) => void;
}

export const useStorageStore = create<StorageState>(
  (set, get) => ({
    data: null,
    isLoaded: false,

    setData: (data) => {
      set({ data });
    },

    loadFromStorage: (key) => {
      const stored = safeStorage.getItem(key);
      if (stored) {
        try {
          const data = JSON.parse(stored);
          set({ data, isLoaded: true });
        } catch (error) {
          console.warn(
            'Failed to parse stored data:',
            error
          );
          set({ isLoaded: true });
        }
      } else {
        set({ isLoaded: true });
      }
    },

    saveToStorage: (key) => {
      const { data } = get();
      if (data) {
        safeStorage.setItem(key, JSON.stringify(data));
      }
    },
  })
);

☐ Step 3: Hydration-safe なコンポーネント実装

typescript// components/StorageSafeComponent.tsx - ストレージ安全なコンポーネント
import { useEffect } from 'react';
import { useStorageStore } from '../store/storageStore';

interface Props {
  storageKey: string;
  fallback?: React.ReactNode;
}

export const StorageSafeComponent: React.FC<Props> = ({
  storageKey,
  fallback = <div>読み込み中...</div>,
}) => {
  const { data, isLoaded, loadFromStorage } =
    useStorageStore();

  useEffect(() => {
    if (!isLoaded) {
      loadFromStorage(storageKey);
    }
  }, [isLoaded, loadFromStorage, storageKey]);

  // ローディング中は fallback を表示
  if (!isLoaded) {
    return <>{fallback}</>;
  }

  // データがない場合のデフォルト表示
  if (!data) {
    return <div>データがありません</div>;
  }

  return (
    <div>
      <h3>保存されたデータ</h3>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

☐ Step 4: 自動保存機能の実装

typescript// hooks/useAutoSave.ts - 自動保存フック
import { useEffect, useRef } from 'react';
import { useStorageStore } from '../store/storageStore';

export const useAutoSave = (
  storageKey: string,
  delay = 1000
) => {
  const { data, saveToStorage } = useStorageStore();
  const timeoutRef = useRef<NodeJS.Timeout>();

  useEffect(() => {
    if (data && typeof window !== 'undefined') {
      // デバウンス処理
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      timeoutRef.current = setTimeout(() => {
        saveToStorage(storageKey);
        console.log(
          'データを自動保存しました:',
          storageKey
        );
      }, delay);
    }

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [data, saveToStorage, storageKey, delay]);
};

具体例

実際のエラーケースとその解決方法

実際のプロジェクトで発生したエラーケースを、エラーメッセージから解決まで詳しく見ていきましょう。

ケース 1: ユーザー認証状態の不整合

typescript// 問題のあるコード例
// store/authStore.ts
import { create } from 'zustand';

interface AuthState {
  user: User | null;
  isLoggedIn: boolean;
  login: (user: User) => void;
  logout: () => void;
}

// 問題:初期化時にlocalStorageを直接参照
const getStoredUser = (): User | null => {
  const stored = localStorage.getItem('user'); // SSRでエラー
  return stored ? JSON.parse(stored) : null;
};

export const useAuthStore = create<AuthState>((set) => ({
  user: getStoredUser(), // Hydration Mismatch の原因
  isLoggedIn: !!getStoredUser(),
  login: (user) => {
    localStorage.setItem('user', JSON.stringify(user));
    set({ user, isLoggedIn: true });
  },
  logout: () => {
    localStorage.removeItem('user');
    set({ user: null, isLoggedIn: false });
  },
}));

エラーメッセージ:

vbnetWarning: Text content did not match. Server: "ログイン" Client: "ユーザー名: 田中太郎"
Error: Hydration failed because the initial UI does not match what was rendered on the server.

解決後のコード:

typescript// 修正されたコード例
// store/authStore.ts
import { create } from 'zustand';
import { safeStorage } from '../utils/safeStorage';

interface AuthState {
  user: User | null;
  isLoggedIn: boolean;
  isHydrated: boolean;
  login: (user: User) => void;
  logout: () => void;
  hydrate: () => void;
}

export const useAuthStore = create<AuthState>(
  (set, get) => ({
    user: null, // 常にnullで初期化
    isLoggedIn: false,
    isHydrated: false,

    login: (user) => {
      safeStorage.setItem('user', JSON.stringify(user));
      set({ user, isLoggedIn: true });
    },

    logout: () => {
      safeStorage.removeItem('user');
      set({ user: null, isLoggedIn: false });
    },

    // Hydration後に実行される
    hydrate: () => {
      const stored = safeStorage.getItem('user');
      if (stored) {
        try {
          const user = JSON.parse(stored);
          set({ user, isLoggedIn: true, isHydrated: true });
        } catch (error) {
          console.warn(
            'Failed to parse stored user:',
            error
          );
          set({ isHydrated: true });
        }
      } else {
        set({ isHydrated: true });
      }
    },
  })
);
typescript// components/AuthenticatedLayout.tsx
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';

export const AuthenticatedLayout: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const { user, isLoggedIn, isHydrated, hydrate } =
    useAuthStore();

  useEffect(() => {
    if (!isHydrated) {
      hydrate();
    }
  }, [isHydrated, hydrate]);

  // Hydration前は中立な表示
  if (!isHydrated) {
    return (
      <div className='layout'>
        <header>
          <h1>アプリケーション</h1>
          <div>読み込み中...</div>
        </header>
        <main>{children}</main>
      </div>
    );
  }

  return (
    <div className='layout'>
      <header>
        <h1>アプリケーション</h1>
        <div>
          {isLoggedIn ? (
            <span>ユーザー名: {user?.name}</span>
          ) : (
            <button>ログイン</button>
          )}
        </div>
      </header>
      <main>{children}</main>
    </div>
  );
};

ケース 2: テーマ切り替え機能の不整合

typescript// 修正前の問題コード
// store/themeStore.ts
import { create } from 'zustand';

type Theme = 'light' | 'dark';

interface ThemeState {
  theme: Theme;
  toggleTheme: () => void;
}

// 問題:初期化時にlocalStorageを参照
export const useThemeStore = create<ThemeState>(
  (set, get) => ({
    theme:
      (localStorage.getItem('theme') as Theme) || 'light', // SSRでエラー
    toggleTheme: () => {
      const current = get().theme;
      const newTheme =
        current === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', newTheme);
      set({ theme: newTheme });
    },
  })
);

修正後の実装:

typescript// store/themeStore.ts - 修正版
import { create } from 'zustand';
import { safeStorage } from '../utils/safeStorage';

type Theme = 'light' | 'dark';

interface ThemeState {
  theme: Theme;
  isThemeLoaded: boolean;
  toggleTheme: () => void;
  loadTheme: () => void;
}

export const useThemeStore = create<ThemeState>(
  (set, get) => ({
    theme: 'light', // デフォルト値で統一
    isThemeLoaded: false,

    toggleTheme: () => {
      const current = get().theme;
      const newTheme =
        current === 'light' ? 'dark' : 'light';
      safeStorage.setItem('theme', newTheme);
      set({ theme: newTheme });
    },

    loadTheme: () => {
      const stored = safeStorage.getItem('theme') as Theme;
      if (stored && ['light', 'dark'].includes(stored)) {
        set({ theme: stored, isThemeLoaded: true });
      } else {
        set({ isThemeLoaded: true });
      }
    },
  })
);
typescript// components/ThemeProvider.tsx - 修正版
import { useEffect } from 'react';
import { useThemeStore } from '../store/themeStore';

export const ThemeProvider: React.FC<{
  children: React.ReactNode;
}> = ({ children }) => {
  const { theme, isThemeLoaded, loadTheme } =
    useThemeStore();

  useEffect(() => {
    if (!isThemeLoaded) {
      loadTheme();
    }
  }, [isThemeLoaded, loadTheme]);

  // CSSカスタムプロパティを使用してテーマを適用
  useEffect(() => {
    if (isThemeLoaded) {
      document.documentElement.setAttribute(
        'data-theme',
        theme
      );
    }
  }, [theme, isThemeLoaded]);

  return (
    <div
      className={
        isThemeLoaded
          ? `theme-loaded theme-${theme}`
          : 'theme-loading'
      }
    >
      {children}
    </div>
  );
};

ケース 3: カート機能での状態不整合

修正前後の比較を通じて、適切な実装パターンを確認しましょう。

typescript// store/cartStore.ts - 修正版
import { create } from 'zustand';
import { safeStorage } from '../utils/safeStorage';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  isCartLoaded: boolean;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  loadCart: () => void;
  saveCart: () => void;
}

export const useCartStore = create<CartState>(
  (set, get) => ({
    items: [], // 空配列で初期化
    isCartLoaded: false,

    addItem: (item) => {
      const { items } = get();
      const existingItem = items.find(
        (i) => i.id === item.id
      );

      let newItems;
      if (existingItem) {
        newItems = items.map((i) =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      } else {
        newItems = [...items, { ...item, quantity: 1 }];
      }

      set({ items: newItems });
      get().saveCart();
    },

    removeItem: (id) => {
      const { items } = get();
      const newItems = items.filter(
        (item) => item.id !== id
      );
      set({ items: newItems });
      get().saveCart();
    },

    loadCart: () => {
      const stored = safeStorage.getItem('cart');
      if (stored) {
        try {
          const items = JSON.parse(stored);
          set({ items, isCartLoaded: true });
        } catch (error) {
          console.warn('Failed to parse cart data:', error);
          set({ isCartLoaded: true });
        }
      } else {
        set({ isCartLoaded: true });
      }
    },

    saveCart: () => {
      const { items } = get();
      safeStorage.setItem('cart', JSON.stringify(items));
    },
  })
);
typescript// components/Cart.tsx - カートコンポーネント
import { useEffect } from 'react';
import { useCartStore } from '../store/cartStore';

export const Cart = () => {
  const { items, isCartLoaded, loadCart } = useCartStore();

  useEffect(() => {
    if (!isCartLoaded) {
      loadCart();
    }
  }, [isCartLoaded, loadCart]);

  if (!isCartLoaded) {
    return (
      <div className='cart'>
        <h2>カート</h2>
        <div>読み込み中...</div>
      </div>
    );
  }

  const totalPrice = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <div className='cart'>
      <h2>カート ({items.length})</h2>
      {items.length === 0 ? (
        <p>カートは空です</p>
      ) : (
        <>
          <ul>
            {items.map((item) => (
              <li key={item.id}>
                {item.name} - {item.price}円 ×{' '}
                {item.quantity}
              </li>
            ))}
          </ul>
          <div>合計: {totalPrice}円</div>
        </>
      )}
    </div>
  );
};

これらの修正により、Hydration Mismatch を完全に解決できました。重要なポイントは以下の通りです:

修正ポイント修正前修正後
初期状態ブラウザ API 依存一定の値
状態復元初期化時useEffect 内
エラーハンドリングなしtry-catch 追加
ローディング状態なし専用フラグ追加

まとめ

予防策とベストプラクティス

Hydration Mismatch を根本的に防ぐためには、以下のベストプラクティスを実践することが重要です。

開発時の基本原則:

typescript// 1. 統一された初期状態を常に使用
const INITIAL_STATE = {
  user: null,
  isLoaded: false,
  // 動的な値は避ける
};

// 2. ブラウザAPIは useEffect 内でのみ使用
useEffect(() => {
  // 安全なタイミングでのAPI呼び出し
  const stored = localStorage.getItem('key');
  if (stored) {
    setState(JSON.parse(stored));
  }
}, []);

// 3. 条件分岐は isLoaded フラグで制御
if (!isLoaded) {
  return <LoadingComponent />;
}

実装チェックリスト:

チェック項目確認内容重要度
☐ 初期状態統一サーバー・クライアントで同じ初期値を使用
☐ ブラウザ API 制御localStorage 等は useEffect 内でのみ使用
☐ ローディング状態適切なローディング表示を実装
☐ エラーハンドリングJSON.parse 等に try-catch を追加
☐ デバッグログ開発時のログ出力を適切に配置

推奨する開発フロー:

mermaidflowchart TD
  start[開発開始] --> design[状態設計]
  design --> initial[初期状態定義]
  initial --> implement[実装]
  implement --> test[テスト]
  test --> check{Hydration OK?}
  check -->|Yes| deploy[デプロイ]
  check -->|No| debug[デバッグ]
  debug --> fix[修正]
  fix --> test

長期的な保守性を高める工夫:

typescript// utils/hydrationSafeStore.ts - 再利用可能なパターン
import { create } from 'zustand';
import { safeStorage } from './safeStorage';

export function createHydrationSafeStore<T>(
  initialState: T,
  storageKey?: string
) {
  return create<
    T & {
      isHydrated: boolean;
      hydrate: () => void;
    }
  >((set, get) => ({
    ...initialState,
    isHydrated: false,

    hydrate: () => {
      if (storageKey) {
        const stored = safeStorage.getItem(storageKey);
        if (stored) {
          try {
            const data = JSON.parse(stored);
            set({ ...data, isHydrated: true });
            return;
          } catch (error) {
            console.warn(
              `Failed to hydrate ${storageKey}:`,
              error
            );
          }
        }
      }
      set({ isHydrated: true });
    },
  }));
}

このパターンを使用することで、一貫性のある Hydration-safe なストアを効率的に作成できます。

適切な実装により、Zustand × Next.js の組み合わせでも安定したアプリケーションを構築できるでしょう。 重要なのは、サーバーサイドとクライアントサイドで同じ状態を維持し、ブラウザ API への依存を適切に管理することです。

関連リンク