T-CREATOR

Preact で Hydration mismatch が出る原因と完全解決チェックリスト

Preact で Hydration mismatch が出る原因と完全解決チェックリスト

Preact でサーバーサイドレンダリング(SSR)を実装していると、ある日突然コンソールに「Hydration mismatch」というエラーが表示されて困惑した経験はありませんか?このエラーは一見すると原因がわかりにくく、デバッグに時間がかかることも多いですよね。

本記事では、Preact における Hydration mismatch の仕組みから、発生する具体的な原因、そして実践的な解決方法まで、初心者の方にもわかりやすく解説していきます。実際のコード例を交えながら、エラーを完全に解消するためのチェックリストもご紹介しますので、ぜひ最後までお読みください。

背景

Hydration とは何か

Hydration(ハイドレーション)は、サーバー側で生成された静的な HTML に、クライアント側で JavaScript を適用して動的な機能を付加するプロセスです。この技術により、初回表示の高速化と SEO の最適化を両立できます。

Preact では、preact-render-to-string を使ってサーバー側で HTML を生成し、クライアント側で hydrate 関数を使ってその HTML に機能を結合します。この 2 つのプロセスが完全に一致している必要があるのですが、何らかの理由で不一致が発生すると Hydration mismatch エラーが発生するのです。

以下の図は、Hydration の基本的なフローを示しています。

mermaidflowchart TB
    server["サーバー側"] -->|renderToString| html["静的HTML<br/>生成"]
    html -->|レスポンス| browser["ブラウザ"]
    browser -->|HTML表示| display["初回表示"]
    display -->|hydrate関数| check["DOM構造<br/>チェック"]
    check -->|一致| success["Hydration<br/>成功"]
    check -->|不一致| error["Hydration<br/>mismatch"]
    error -->|警告表示| console["コンソール<br/>エラー"]

このように、サーバーとクライアントで生成される DOM が一致しない場合にエラーが発生します。

SSR と CSR の違い

SSR(Server-Side Rendering)と CSR(Client-Side Rendering)では、HTML の生成タイミングが異なります。

#項目SSRCSR
1HTML 生成場所サーバーブラウザ
2初回表示速度高速やや遅い
3SEO 対応優れている限定的
4JavaScript 必須初回表示には不要必須
5Hydration必要不要

SSR を採用する最大のメリットは初回表示の高速化ですが、その代わりに Hydration という追加のプロセスが必要になります。このプロセスで不整合が発生すると、せっかくの SSR のメリットが損なわれてしまうのです。

Preact の Hydration の仕組み

Preact は React と比較して軽量ですが、Hydration の基本原理は同じです。ただし、いくつかの違いがありますので理解しておきましょう。

typescript// Preact の hydrate 関数のインポート
import { hydrate } from 'preact';

Preact の hydrate 関数は、既存の DOM 要素に対してコンポーネントツリーをマウントします。この際、サーバー側で生成された HTML と、クライアント側で生成される仮想 DOM を比較して整合性をチェックするのです。

typescript// クライアント側での hydrate 実行例
import { hydrate } from 'preact';
import App from './App';

// サーバーで生成された HTML に機能を追加
const root = document.getElementById('root');
if (root) {
  hydrate(<App />, root);
}

この処理で、Preact は以下のポイントをチェックします。

  • DOM ノードの種類(要素名、テキストノードなど)
  • 属性の種類と値
  • 子要素の数と順序
  • テキストコンテンツ

これらのいずれかに不一致があると、Hydration mismatch が発生します。

課題

Hydration mismatch が発生する主な原因

Hydration mismatch は様々な原因で発生しますが、多くの場合は以下のカテゴリーに分類できます。

mermaidflowchart TD
    mismatch["Hydration<br/>mismatch"]
    mismatch --> env["環境依存"]
    mismatch --> timing["タイミング依存"]
    mismatch --> state["状態管理"]
    mismatch --> random["ランダム性"]

    env --> window_obj["window/document<br/>オブジェクト"]
    env --> ua["User Agent<br/>判定"]

    timing --> date["Date/時刻"]
    timing --> random_num["Math.random"]

    state --> initial["初期状態の<br/>不一致"]
    state --> async_data["非同期データ<br/>取得"]

    random --> uuid["UUID生成"]
    random --> shuffle["配列シャッフル"]

上記の図は、Hydration mismatch が発生する主な原因を分類したものです。それぞれの原因について、具体的に見ていきましょう。

環境依存の問題

サーバーとブラウザでは実行環境が異なるため、環境依存のコードを書くと不一致が発生します。

問題のあるコード例

typescript// ❌ NG: window オブジェクトを直接参照
function MyComponent() {
  // サーバー側では window が存在しないため undefined になる
  const width = window.innerWidth;

  return <div>Width: {width}px</div>;
}

このコードでは、サーバー側で window.innerWidthundefined になり、クライアント側では実際の値(例: 1920)になるため、生成される HTML が異なってしまいます。

typescript// ❌ NG: document オブジェクトを使用
function ThemeComponent() {
  // サーバー側では document が存在しない
  const theme = document.body.classList.contains('dark') ? 'dark' : 'light';

  return <div className={theme}>Content</div>;
}

タイミング依存の問題

時刻や乱数など、実行タイミングによって値が変わるものを使用すると、サーバーとクライアントで異なる結果になります。

Date オブジェクトの使用

typescript// ❌ NG: レンダリング時に毎回異なる時刻を取得
function TimeDisplay() {
  // サーバー側とクライアント側で実行タイミングが異なる
  const now = new Date().toLocaleString();

  return <div>Current time: {now}</div>;
}

サーバーでレンダリングされた時刻と、ブラウザで hydrate が実行される時刻は必ず異なるため、確実に mismatch が発生します。

Math.random の使用

typescript// ❌ NG: ランダムな値を生成
function RandomComponent() {
  // 毎回異なる値が生成される
  const randomId = Math.random().toString(36).substr(2, 9);

  return <div id={randomId}>Random content</div>;
}

状態管理の問題

初期状態の設定や非同期データの取得タイミングによっても mismatch が発生します。

初期状態の不一致

typescript// ❌ NG: useState の初期値が環境依存
function StorageComponent() {
  // サーバー側では localStorage が存在しないため null
  // クライアント側では実際の値を取得
  const [value, setValue] = useState(
    localStorage.getItem('savedValue')
  );

  return <div>{value || 'No value'}</div>;
}

非同期データ取得のタイミング

typescript// ❌ NG: useEffect でデータ取得
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // useEffect はクライアント側でのみ実行される
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  // サーバー側: null が表示される
  // クライアント側: データ取得後に user 情報が表示される
  return <div>{user ? user.name : 'Loading...'}</div>;
}

エラーメッセージの読み方

Hydration mismatch が発生すると、以下のようなエラーメッセージがコンソールに表示されます。

エラーコード: Preact では明示的なエラーコードはありませんが、React 互換モードでは Warning: Text content does not match などのメッセージが表示されます。

エラーメッセージ例:

textWarning: Text content does not match. Server: "1000" Client: "1920"
Warning: Expected server HTML to contain a matching <div> in <div>

発生条件:

  • サーバー側とクライアント側で異なる DOM 構造が生成される
  • テキストコンテンツが一致しない
  • 属性値が異なる
  • 子要素の数や順序が異なる

これらのエラーは、開発モードでのみ詳細に表示されますが、本番環境でもパフォーマンスに悪影響を与える可能性があります。

解決策

基本的な解決アプローチ

Hydration mismatch を解決するための基本原則は「サーバーとクライアントで同じ HTML を生成すること」です。以下の 3 つのアプローチを使い分けましょう。

mermaidflowchart LR
    problem["Hydration<br/>mismatch"]
    problem --> approach1["アプローチ1<br/>環境判定"]
    problem --> approach2["アプローチ2<br/>2段階レンダリング"]
    problem --> approach3["アプローチ3<br/>データ同期"]

    approach1 --> result1["typeof window<br/>チェック"]
    approach2 --> result2["useEffect<br/>活用"]
    approach3 --> result3["SSR時に<br/>データ渡す"]

それぞれのアプローチについて、具体的なコード例とともに解説していきます。

アプローチ 1: 環境判定を使った条件分岐

環境依存のコードは、サーバーとクライアントを判定して適切に処理しましょう。

環境判定ユーティリティの作成

typescript// utils/environment.ts
// サーバー側かどうかを判定するユーティリティ関数
export const isServer = typeof window === 'undefined';

// クライアント側かどうかを判定
export const isClient = typeof window !== 'undefined';

// 安全に window オブジェクトにアクセス
export const getWindow = () => {
  return isClient ? window : undefined;
};

このユーティリティを使用することで、環境に応じた安全な処理が可能になります。

window オブジェクトの安全な使用

typescript// components/WindowWidth.tsx
import { useState, useEffect } from 'preact/hooks';
import { isClient } from '../utils/environment';

function WindowWidth() {
  // 初期値はサーバーとクライアントで同じ値を使用
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    // クライアント側でのみ実行される
    if (isClient) {
      setWidth(window.innerWidth);

      const handleResize = () => setWidth(window.innerWidth);
      window.addEventListener('resize', handleResize);

      return () => window.removeEventListener('resize', handleResize);
    }
  }, []);

  // 初回レンダリング時は null を表示(サーバーと一致)
  if (width === null) {
    return <div>Calculating width...</div>;
  }

  return <div>Width: {width}px</div>;
}

export default WindowWidth;

このパターンでは、初回レンダリング時にサーバーとクライアントで同じ HTML(Calculating width...)を生成し、その後 useEffect でクライアント側のみ実際の幅を取得します。

localStorage の安全な使用

typescript// components/StorageComponent.tsx
import { useState, useEffect } from 'preact/hooks';

function StorageComponent() {
  // 初期値は常に null(サーバーとクライアントで一致)
  const [savedValue, setSavedValue] = useState<string | null>(null);

  useEffect(() => {
    // クライアント側でのみ localStorage から取得
    const value = localStorage.getItem('savedValue');
    setSavedValue(value);
  }, []);

  const handleSave = (newValue: string) => {
    localStorage.setItem('savedValue', newValue);
    setSavedValue(newValue);
  };

  return (
    <div>
      <p>Saved: {savedValue || 'No saved value'}</p>
      <button onClick={() => handleSave('New value')}>
        Save Value
      </button>
    </div>
  );
}

export default StorageComponent;

アプローチ 2: 2 段階レンダリング

クライアント専用のコンテンツは、初回レンダリング後に表示する方法が効果的です。

クライアント専用コンポーネントの作成

typescript// components/ClientOnly.tsx
import { useState, useEffect } from 'preact/hooks';
import { ComponentChildren } from 'preact';

interface ClientOnlyProps {
  children: ComponentChildren;
  fallback?: ComponentChildren;
}

function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
  // サーバー側では false、クライアント側で true になる
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    // クライアント側でマウントされた後に true に設定
    setIsClient(true);
  }, []);

  // サーバー側とクライアントの初回レンダリングでは fallback を表示
  if (!isClient) {
    return <>{fallback}</>;
  }

  // クライアント側の 2 回目以降のレンダリングで children を表示
  return <>{children}</>;
}

export default ClientOnly;

ClientOnly コンポーネントの活用

typescript// components/UserDashboard.tsx
import ClientOnly from './ClientOnly';
import RealtimeChart from './RealtimeChart';

function UserDashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* サーバー側では fallback を表示 */}
      <ClientOnly fallback={<div>Loading chart...</div>}>
        {/* クライアント側でのみレンダリング */}
        <RealtimeChart />
      </ClientOnly>

      <div>Other static content</div>
    </div>
  );
}

export default UserDashboard;

このパターンを使用することで、環境依存のコンポーネントを安全に扱えます。

動的インポートとの組み合わせ

typescript// components/LazyClientComponent.tsx
import { useState, useEffect } from 'preact/hooks';

function LazyClientComponent() {
  const [Component, setComponent] = useState<any>(null);

  useEffect(() => {
    // クライアント側でのみ動的にインポート
    import('./HeavyComponent')
      .then(mod => setComponent(() => mod.default))
      .catch(err => console.error('Failed to load component:', err));
  }, []);

  if (!Component) {
    return <div>Loading component...</div>;
  }

  return <Component />;
}

export default LazyClientComponent;

アプローチ 3: データの同期

サーバー側で生成したデータをクライアント側に渡すことで、一貫性を保ちます。

サーバーデータの埋め込み

typescript// server.ts(サーバー側のコード)
import { renderToString } from 'preact-render-to-string';
import App from './App';

function renderPage(initialData: any) {
  // コンポーネントをレンダリング
  const html = renderToString(<App initialData={initialData} />);

  // 初期データを JSON として HTML に埋め込む
  const page = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <!-- サーバー側のデータをクライアントに渡す -->
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
        </script>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `;

  return page;
}

サーバー側で生成したデータを window.__INITIAL_DATA__ として埋め込むことで、クライアント側でも同じデータを使用できます。

クライアント側でのデータ取得

typescript// client.ts(クライアント側のコード)
import { hydrate } from 'preact';
import App from './App';

// グローバルオブジェクトの型定義
declare global {
  interface Window {
    __INITIAL_DATA__?: any;
  }
}

// サーバーから渡されたデータを取得
const initialData = window.__INITIAL_DATA__ || {};

// 同じデータを使って hydrate
const root = document.getElementById('root');
if (root) {
  hydrate(<App initialData={initialData} />, root);
}

コンポーネントでの使用例

typescript// App.tsx
import { useState } from 'preact/hooks';

interface AppProps {
  initialData: {
    user: { name: string; id: string };
    timestamp: number;
  };
}

function App({ initialData }: AppProps) {
  // サーバーとクライアントで同じ初期データを使用
  const [user] = useState(initialData.user);

  // サーバー側で生成された timestamp を使用
  const formattedTime = new Date(initialData.timestamp).toLocaleString();

  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      <p>Page generated at: {formattedTime}</p>
    </div>
  );
}

export default App;

このアプローチにより、サーバーとクライアントで完全に同じデータを使用できるため、Hydration mismatch を回避できます。

時刻・ランダム値の扱い方

時刻やランダム値は、サーバー側で生成してクライアントに渡すことが基本です。

時刻の正しい扱い方

typescript// components/TimeDisplay.tsx
interface TimeDisplayProps {
  // サーバー側から渡されたタイムスタンプ
  timestamp: number;
}

function TimeDisplay({ timestamp }: TimeDisplayProps) {
  // Props として受け取った timestamp を使用
  const formattedTime = new Date(timestamp).toLocaleString();

  return <div>Generated at: {formattedTime}</div>;
}

export default TimeDisplay;

UUID の生成

typescript// server.ts
import { v4 as uuidv4 } from 'uuid';
import { renderToString } from 'preact-render-to-string';
import App from './App';

function renderPage() {
  // サーバー側で UUID を生成
  const sessionId = uuidv4();

  const initialData = {
    sessionId,
    timestamp: Date.now(),
  };

  const html = renderToString(<App initialData={initialData} />);

  return `
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
        </script>
      </body>
    </html>
  `;
}

クライアント側でのリアルタイム更新

typescript// components/LiveClock.tsx
import { useState, useEffect } from 'preact/hooks';

interface LiveClockProps {
  // サーバーから渡された初期時刻
  initialTimestamp: number;
}

function LiveClock({ initialTimestamp }: LiveClockProps) {
  // 初期値はサーバーから渡された時刻を使用
  const [currentTime, setCurrentTime] = useState(initialTimestamp);

  useEffect(() => {
    // クライアント側でのみタイマーを開始
    const timer = setInterval(() => {
      setCurrentTime(Date.now());
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return (
    <div>
      {new Date(currentTime).toLocaleTimeString()}
    </div>
  );
}

export default LiveClock;

このパターンにより、初回レンダリングではサーバーとクライアントで同じ時刻を表示し、その後クライアント側でリアルタイムに更新できます。

具体例

ケーススタディ 1: ダークモード切り替え

ダークモードの実装は、Hydration mismatch が発生しやすい典型的なケースです。以下の実装パターンを見ていきましょう。

問題のある実装

typescript// ❌ NG: 初回レンダリング時に localStorage を直接参照
function DarkModeToggle() {
  // サーバー側では undefined、クライアント側では 'dark' または 'light'
  const [theme, setTheme] = useState(
    localStorage.getItem('theme') || 'light'
  );

  return (
    <div className={theme}>
      <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
        Toggle theme
      </button>
    </div>
  );
}

この実装では、サーバー側で localStorage が存在しないため、必ず mismatch が発生します。

正しい実装パターン

typescript// ✅ OK: 2段階レンダリングを使用
import { useState, useEffect } from 'preact/hooks';

function DarkModeToggle() {
  // 初期値はデフォルトテーマ(サーバーとクライアントで一致)
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  // マウント済みかどうかを管理
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    // クライアント側でマウント後に実行
    setMounted(true);

    // localStorage から保存されたテーマを取得
    const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
    if (savedTheme) {
      setTheme(savedTheme);
    }
  }, []);

  const toggleTheme = () => {
    const newTheme = theme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  // マウント前は UI を非表示にするか、デフォルトを表示
  if (!mounted) {
    return <div className="light">Loading theme...</div>;
  }

  return (
    <div className={theme}>
      <button onClick={toggleTheme}>
        {theme === 'dark' ? '☀️ Light' : '🌙 Dark'} Mode
      </button>
    </div>
  );
}

export default DarkModeToggle;

より洗練された実装

typescript// hooks/useTheme.ts
import { useState, useEffect } from 'preact/hooks';

type Theme = 'light' | 'dark';

export function useTheme() {
  const [theme, setTheme] = useState<Theme>('light');
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    // localStorage からテーマを取得
    const savedTheme = localStorage.getItem('theme') as Theme;

    // システムのカラースキームを確認
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    // 優先順位: localStorage > システム設定 > デフォルト
    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');

    setTheme(initialTheme);
    setIsReady(true);

    // システム設定の変更を監視
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handleChange = (e: MediaQueryListEvent) => {
      if (!localStorage.getItem('theme')) {
        setTheme(e.matches ? 'dark' : 'light');
      }
    };

    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

  const toggleTheme = () => {
    const newTheme = theme === 'dark' ? 'light' : 'dark';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return { theme, toggleTheme, isReady };
}

カスタムフックの使用

typescript// components/ThemedApp.tsx
import { useTheme } from '../hooks/useTheme';

function ThemedApp() {
  const { theme, toggleTheme, isReady } = useTheme();

  // テーマ準備完了まで最小限の UI を表示
  if (!isReady) {
    return (
      <div className="app-loading">
        <style>{`
          .app-loading {
            background: #fff;
            min-height: 100vh;
          }
        `}</style>
        Loading...
      </div>
    );
  }

  return (
    <div className={`app theme-${theme}`}>
      <header>
        <button onClick={toggleTheme}>
          Toggle to {theme === 'dark' ? 'Light' : 'Dark'} Mode
        </button>
      </header>
      <main>
        <h1>My App</h1>
        <p>Current theme: {theme}</p>
      </main>
    </div>
  );
}

export default ThemedApp;

以下の図は、ダークモード実装のフローを示しています。

mermaidsequenceDiagram
    participant Server
    participant Browser
    participant LocalStorage
    participant Component

    Server->>Browser: HTML送信(デフォルトテーマ)
    Browser->>Component: 初回レンダリング
    Component->>Component: useState('light')
    Component->>Browser: デフォルト表示

    Note over Browser,Component: Hydration完了

    Component->>LocalStorage: テーマ取得
    LocalStorage-->>Component: 'dark'
    Component->>Component: setTheme('dark')
    Component->>Browser: ダークモード表示

    Browser->>Component: ユーザーがトグル
    Component->>LocalStorage: テーマ保存('light')
    Component->>Browser: 表示更新

このフローにより、初回レンダリングでは一致したHTMLを生成し、その後スムーズにユーザーの設定を反映できます。

ケーススタディ 2: ユーザー認証状態の表示

ユーザー認証状態の表示も、Hydration mismatch が発生しやすいケースです。

サーバー側での認証情報の取得

typescript// server/auth.ts
import { Request } from 'express';

// JWT トークンからユーザー情報を取得
export async function getUserFromRequest(req: Request) {
  try {
    const token = req.cookies.auth_token;
    if (!token) return null;

    // トークンを検証してユーザー情報を取得
    const user = await verifyToken(token);
    return user;
  } catch (error) {
    console.error('Auth error:', error);
    return null;
  }
}

サーバーサイドレンダリング

typescript// server/render.ts
import { renderToString } from 'preact-render-to-string';
import App from '../App';
import { getUserFromRequest } from './auth';

export async function renderAppPage(req: Request) {
  // サーバー側でユーザー情報を取得
  const user = await getUserFromRequest(req);

  const initialData = {
    user,
    timestamp: Date.now(),
  };

  // ユーザー情報を含めてレンダリング
  const html = renderToString(<App initialData={initialData} />);

  return `
    <!DOCTYPE html>
    <html>
      <head>
        <title>My App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
        </script>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `;
}

クライアント側での実装

typescript// components/UserHeader.tsx
import { useState, useEffect } from 'preact/hooks';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserHeaderProps {
  initialUser: User | null;
}

function UserHeader({ initialUser }: UserHeaderProps) {
  // サーバーから渡された初期ユーザー情報を使用
  const [user, setUser] = useState<User | null>(initialUser);

  useEffect(() => {
    // クライアント側でユーザー情報の更新を監視
    const checkAuth = async () => {
      try {
        const response = await fetch('/api/auth/me');
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        } else {
          setUser(null);
        }
      } catch (error) {
        console.error('Failed to check auth:', error);
      }
    };

    // 定期的に認証状態を確認
    const interval = setInterval(checkAuth, 60000); // 1分ごと

    return () => clearInterval(interval);
  }, []);

  if (!user) {
    return (
      <header>
        <a href="/login">Login</a>
      </header>
    );
  }

  return (
    <header>
      <span>Welcome, {user.name}!</span>
      <button onClick={() => {
        fetch('/api/auth/logout', { method: 'POST' })
          .then(() => setUser(null));
      }}>
        Logout
      </button>
    </header>
  );
}

export default UserHeader;

ケーススタディ 3: 動的なコンテンツリスト

非同期でデータを取得して表示するリストも、適切な実装が必要です。

サーバー側でのデータ取得

typescript// server/data.ts
export async function fetchArticles() {
  try {
    // データベースや API からデータを取得
    const articles = await db.articles.findMany({
      orderBy: { createdAt: 'desc' },
      take: 10,
    });

    return articles;
  } catch (error) {
    console.error('Failed to fetch articles:', error);
    return [];
  }
}

サーバーサイドレンダリングでのデータ埋め込み

typescript// server/render-articles.ts
import { renderToString } from 'preact-render-to-string';
import ArticleList from '../components/ArticleList';
import { fetchArticles } from './data';

export async function renderArticlesPage() {
  // サーバー側でデータを取得
  const articles = await fetchArticles();

  const initialData = {
    articles,
    timestamp: Date.now(),
  };

  const html = renderToString(
    <ArticleList initialArticles={articles} />
  );

  return `
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
        </script>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `;
}

コンポーネントの実装

typescript// components/ArticleList.tsx
import { useState, useEffect } from 'preact/hooks';

interface Article {
  id: string;
  title: string;
  excerpt: string;
  createdAt: string;
}

interface ArticleListProps {
  initialArticles: Article[];
}

function ArticleList({ initialArticles }: ArticleListProps) {
  // サーバーから渡された初期データを使用
  const [articles, setArticles] = useState<Article[]>(initialArticles);
  const [loading, setLoading] = useState(false);

  const refreshArticles = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/articles');
      const data = await response.json();
      setArticles(data);
    } catch (error) {
      console.error('Failed to refresh articles:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <div className="header">
        <h1>Articles</h1>
        <button onClick={refreshArticles} disabled={loading}>
          {loading ? 'Refreshing...' : 'Refresh'}
        </button>
      </div>

      <div className="article-list">
        {articles.length === 0 ? (
          <p>No articles found.</p>
        ) : (
          articles.map(article => (
            <article key={article.id}>
              <h2>{article.title}</h2>
              <p>{article.excerpt}</p>
              <time>{new Date(article.createdAt).toLocaleDateString()}</time>
            </article>
          ))
        )}
      </div>
    </div>
  );
}

export default ArticleList;

無限スクロールの実装

typescript// components/InfiniteArticleList.tsx
import { useState, useEffect, useRef } from 'preact/hooks';

interface InfiniteArticleListProps {
  initialArticles: Article[];
}

function InfiniteArticleList({ initialArticles }: InfiniteArticleListProps) {
  const [articles, setArticles] = useState<Article[]>(initialArticles);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const observerTarget = useRef<HTMLDivElement>(null);

  const loadMore = async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const response = await fetch(`/api/articles?page=${page + 1}`);
      const data = await response.json();

      if (data.length === 0) {
        setHasMore(false);
      } else {
        setArticles(prev => [...prev, ...data]);
        setPage(prev => prev + 1);
      }
    } catch (error) {
      console.error('Failed to load more:', error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    // Intersection Observer で無限スクロールを実装
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => observer.disconnect();
  }, [page, hasMore, loading]);

  return (
    <div>
      <div className="article-list">
        {articles.map(article => (
          <article key={article.id}>
            <h2>{article.title}</h2>
            <p>{article.excerpt}</p>
          </article>
        ))}
      </div>

      {hasMore && (
        <div ref={observerTarget} className="loading-indicator">
          {loading ? 'Loading more...' : 'Scroll for more'}
        </div>
      )}
    </div>
  );
}

export default InfiniteArticleList;

以下の図は、データ取得とレンダリングのフローを示しています。

mermaidflowchart TD
    request["ページリクエスト"] --> server_fetch["サーバー側で<br/>データ取得"]
    server_fetch --> server_render["SSR実行"]
    server_render --> embed["初期データを<br/>HTML に埋め込み"]
    embed --> send["HTML送信"]
    send --> hydrate["クライアント側で<br/>hydrate"]
    hydrate --> match{"初期データ<br/>一致?"}
    match -->|Yes| success["Hydration<br/>成功"]
    match -->|No| error["Mismatch<br/>発生"]
    success --> client_fetch["必要に応じて<br/>追加データ取得"]

完全解決チェックリスト

Hydration mismatch を完全に解消するための、実践的なチェックリストを用意しました。

環境依存のチェック

#チェック項目詳細対応方法
1window オブジェクトwindow.innerWidth などを直接参照していないかuseEffect 内で取得
2document オブジェクトdocument.getElementById などを使用していないかuseEffect 内で取得
3localStorage初回レンダリング時に参照していないかuseEffect 内で取得
4sessionStorage初回レンダリング時に参照していないかuseEffect 内で取得
5navigatorUser Agent などを直接参照していないかサーバーから渡す

タイミング依存のチェック

#チェック項目詳細対応方法
1Date.now()毎回異なる値を生成していないかサーバーから timestamp を渡す
2Math.random()ランダム値を生成していないかサーバー側で生成して渡す
3UUID 生成コンポーネント内で生成していないかサーバー側で生成
4タイマー処理即座に実行していないかuseEffect で実行
5アニメーション初回から動作させていないかuseEffect で開始

状態管理のチェック

#チェック項目詳細対応方法
1useState 初期値環境依存の値を使用していないか共通の初期値を使用
2useEffect の使用データ取得を適切に行っているか初期データを Props で渡す
3非同期処理初回レンダリングで実行していないかサーバー側で実行
4グローバル状態クライアント専用の状態を使用していないか初期値を同期
5Context の初期値サーバーとクライアントで一致しているかProps で渡す

レンダリング条件のチェック

#チェック項目詳細対応方法
1条件分岐環境依存の条件を使用していないか共通の条件を使用
2配列のマップキーが一意で安定しているか安定したキーを使用
3フラグメント正しく閉じられているか構文チェック
4null/undefined意図しない値を返していないかデフォルト値を設定
5動的コンポーネントクライアント専用か確認ClientOnly で囲む

デバッグ手順

typescript// utils/hydration-debug.ts
// 開発環境でのみ有効なデバッグヘルパー

export function enableHydrationDebug() {
  if (process.env.NODE_ENV !== 'development') return;

  // サーバー側かクライアント側かをログ出力
  const isServer = typeof window === 'undefined';
  console.log(`[Hydration Debug] Running on: ${isServer ? 'Server' : 'Client'}`);

  // レンダリング時の状態をログ出力
  return {
    logRender: (componentName: string, props: any) => {
      console.log(`[${isServer ? 'SSR' : 'CSR'}] ${componentName}`, props);
    },

    logState: (componentName: string, state: any) => {
      console.log(`[State] ${componentName}`, state);
    },
  };
}

デバッグヘルパーの使用

typescript// components/DebugComponent.tsx
import { useState, useEffect } from 'preact/hooks';
import { enableHydrationDebug } from '../utils/hydration-debug';

function DebugComponent({ initialData }: any) {
  const debug = enableHydrationDebug();
  const [data, setData] = useState(initialData);

  // レンダリング時にログ出力
  debug?.logRender('DebugComponent', { initialData });

  useEffect(() => {
    debug?.logState('DebugComponent - mounted', { data });
  }, []);

  return <div>{JSON.stringify(data)}</div>;
}

export default DebugComponent;

まとめ

Preact における Hydration mismatch は、サーバーサイドレンダリングを使用する際に避けて通れない課題ですが、原因を理解し適切な対策を講じることで完全に解決できます。

本記事で解説した内容をまとめますと、以下の点が重要になります。

重要なポイント:

  • 環境の違いを意識する: サーバーとクライアントは異なる環境であることを常に意識し、環境依存のコードは useEffect 内で実行しましょう
  • 初期値を統一する: useState の初期値や Props として渡すデータは、サーバーとクライアントで必ず同じ値を使用してください
  • 2段階レンダリングを活用する: クライアント専用のコンテンツは、初回レンダリング後に表示する方法が効果的です
  • データを同期する: サーバー側で生成したデータは、必ずクライアント側に渡して同じデータを使用しましょう

実装時の基本原則:

  1. サーバーとクライアントで同じ HTML を生成することを最優先にする
  2. windowdocumentlocalStorage などのブラウザ API は useEffect 内でのみ使用する
  3. 時刻やランダム値はサーバー側で生成してクライアントに渡す
  4. 環境判定には typeof window === 'undefined' を使用する
  5. チェックリストを活用して体系的にチェックする

これらのベストプラクティスに従うことで、Hydration mismatch を未然に防ぎ、パフォーマンスとユーザー体験の両方を向上させることができます。エラーが発生した場合も、本記事のチェックリストを参照することで、原因を素早く特定し解決できるでしょう。

SSR と Hydration の仕組みを理解し、適切な実装パターンを身につけることで、より堅牢で保守性の高い Preact アプリケーションを構築できます。ぜひ実際のプロジェクトで実践してみてください。

関連リンク