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 の生成タイミングが異なります。
| # | 項目 | SSR | CSR | 
|---|---|---|---|
| 1 | HTML 生成場所 | サーバー | ブラウザ | 
| 2 | 初回表示速度 | 高速 | やや遅い | 
| 3 | SEO 対応 | 優れている | 限定的 | 
| 4 | JavaScript 必須 | 初回表示には不要 | 必須 | 
| 5 | Hydration | 必要 | 不要 | 
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.innerWidth が undefined になり、クライアント側では実際の値(例: 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 を完全に解消するための、実践的なチェックリストを用意しました。
環境依存のチェック
| # | チェック項目 | 詳細 | 対応方法 | 
|---|---|---|---|
| 1 | window オブジェクト | window.innerWidth などを直接参照していないか | useEffect 内で取得 | 
| 2 | document オブジェクト | document.getElementById などを使用していないか | useEffect 内で取得 | 
| 3 | localStorage | 初回レンダリング時に参照していないか | useEffect 内で取得 | 
| 4 | sessionStorage | 初回レンダリング時に参照していないか | useEffect 内で取得 | 
| 5 | navigator | User Agent などを直接参照していないか | サーバーから渡す | 
タイミング依存のチェック
| # | チェック項目 | 詳細 | 対応方法 | 
|---|---|---|---|
| 1 | Date.now() | 毎回異なる値を生成していないか | サーバーから timestamp を渡す | 
| 2 | Math.random() | ランダム値を生成していないか | サーバー側で生成して渡す | 
| 3 | UUID 生成 | コンポーネント内で生成していないか | サーバー側で生成 | 
| 4 | タイマー処理 | 即座に実行していないか | useEffect で実行 | 
| 5 | アニメーション | 初回から動作させていないか | useEffect で開始 | 
状態管理のチェック
| # | チェック項目 | 詳細 | 対応方法 | 
|---|---|---|---|
| 1 | useState 初期値 | 環境依存の値を使用していないか | 共通の初期値を使用 | 
| 2 | useEffect の使用 | データ取得を適切に行っているか | 初期データを Props で渡す | 
| 3 | 非同期処理 | 初回レンダリングで実行していないか | サーバー側で実行 | 
| 4 | グローバル状態 | クライアント専用の状態を使用していないか | 初期値を同期 | 
| 5 | Context の初期値 | サーバーとクライアントで一致しているか | Props で渡す | 
レンダリング条件のチェック
| # | チェック項目 | 詳細 | 対応方法 | 
|---|---|---|---|
| 1 | 条件分岐 | 環境依存の条件を使用していないか | 共通の条件を使用 | 
| 2 | 配列のマップ | キーが一意で安定しているか | 安定したキーを使用 | 
| 3 | フラグメント | 正しく閉じられているか | 構文チェック | 
| 4 | null/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段階レンダリングを活用する: クライアント専用のコンテンツは、初回レンダリング後に表示する方法が効果的です
 - データを同期する: サーバー側で生成したデータは、必ずクライアント側に渡して同じデータを使用しましょう
 
実装時の基本原則:
- サーバーとクライアントで同じ HTML を生成することを最優先にする
 window、document、localStorageなどのブラウザ API はuseEffect内でのみ使用する- 時刻やランダム値はサーバー側で生成してクライアントに渡す
 - 環境判定には 
typeof window === 'undefined'を使用する - チェックリストを活用して体系的にチェックする
 
これらのベストプラクティスに従うことで、Hydration mismatch を未然に防ぎ、パフォーマンスとユーザー体験の両方を向上させることができます。エラーが発生した場合も、本記事のチェックリストを参照することで、原因を素早く特定し解決できるでしょう。
SSR と Hydration の仕組みを理解し、適切な実装パターンを身につけることで、より堅牢で保守性の高い Preact アプリケーションを構築できます。ぜひ実際のプロジェクトで実践してみてください。
関連リンク
articlePreact で Hydration mismatch が出る原因と完全解決チェックリスト
articlePreact で埋め込みウィジェット配布:他サイトに設置できる軽量 UI の作り方
articlePreact でミニブログを 1 日で公開:ルーティング・MDX・SEO まで一気通貫
articlePreact でスケーラブルな状態管理:Signals/Context/外部ストアの責務分離
articlePreact チートシート【保存版】:JSX/Props/Events/Ref の書き方早見表
articleVite で始める Preact:公式プラグイン設定と最短プロジェクト作成【完全手順】
articleWebSocket が「200 OK で Upgrade されない」原因と対処:プロキシ・ヘッダー・TLS の落とし穴
articleWebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値
articleAstro のレンダリング戦略を一望:MPA× 部分ハイドレーションの強みを図解解説
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleテスト環境比較:Vitest vs Jest vs Playwright CT ― Vite プロジェクトの最適解
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来