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 Signals vs Redux/Zustand:状態管理の速度・記述量・学習コストをベンチマーク
articlePreact アーキテクチャ超入門:VNode・Diff・Renderer を図解で理解
articlePreact 本番最適化運用:Lighthouse 95 点超えのビルド設定と監視 KPI
articlePreact で Hydration mismatch が出る原因と完全解決チェックリスト
articlePreact で埋め込みウィジェット配布:他サイトに設置できる軽量 UI の作り方
articlePreact でミニブログを 1 日で公開:ルーティング・MDX・SEO まで一気通貫
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleバックアップ戦略の決定版:WordPress の世代管理/災害復旧の型
articleYarn 運用ベストプラクティス:lockfile 厳格化・frozen-lockfile・Bot 更新方針
articleWebSocket のペイロード比較:JSON・MessagePack・Protobuf の速度とコスト検証
articleWeb Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集
articleWebRTC SDP 用語チートシート:m=・a=・bundle・rtcp-mux を 10 分で総復習
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来