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[状態同期問題]
原因別の発生頻度と影響度:
順位 | 原因 | 発生頻度 | 影響度 | 解決難易度 |
---|---|---|---|---|
1 | localStorage/sessionStorage 問題 | 高 | 高 | 中 |
2 | 初期状態の不整合問題 | 高 | 中 | 低 |
3 | useEffect タイミング問題 | 中 | 中 | 中 |
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 への依存を適切に管理することです。
関連リンク
- article
Zustand × Next.js の Hydration Mismatch を根絶する:原因別チェックリスト
- article
Zustand を React なしで使う:subscribe と Store API だけで組む最小構成
- article
Zustand の状態管理を使ったカスタムフック作成術
- article
Zustand × URL クエリパラメータ連動:状態同期で UX を高める
- article
Zustand のストア構造を大型プロジェクト向けに最適化する手法
- article
Zustand で一括データ取得・一部更新を効率化する設計法
- article
Next.js で「Dynamic server usage: cookies/headers」はなぜ起きる?原因と解決手順
- article
Zustand × Next.js の Hydration Mismatch を根絶する:原因別チェックリスト
- article
Next.js の Parallel Routes & Intercepting Routes を図解で理解する最新入門
- article
Convex × React/Next.js 最速連携:useQuery/useMutation の実践パターン
- article
Next.js の Middleware 活用法:リクエスト制御・認証・リダイレクトの実践例
- article
【解決策】Next.js での CORS エラーの原因と対処方法まとめ
- article
Next.js で「Dynamic server usage: cookies/headers」はなぜ起きる?原因と解決手順
- article
Mermaid を VS Code で快適に:拡張機能・ライブプレビュー・ショートカット設定
- article
Web Components を Vite + TypeScript + yarn で最短セットアップする完全手順
- article
Lodash を部分インポートで導入する最短ルート:ESM/TS/バンドラ別の設定集
- article
LangChain を Edge で走らせる:Cloudflare Workers/Deno/Bun 対応の初期配線
- article
Vue.js の Hydration mismatch を潰す:SSR/CSR 差異の原因 12 と実践対策
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来