何が起きているか
<Suspense> バウンダリがサーバーでレンダリングされるコンポーネントをラップしています。ハイドレーション — イベントリスナーをアタッチし、サーバーHTMLをクライアントの状態と照合するReactのプロセス — の最中に、そのバウンダリが完了する前に何かが状態の更新をトリガーします。React 18はこの競合を検知してそのサブツリーのハイドレーションを中止し、完全なクライアントレンダリングにフォールバックします。
コンソールに表示される完全なエラー:
Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering.
クライアントフォールバックが発生すると、目に見えるフラッシュ — 通常はページが安定するまでの100〜300ミリ秒の空白コンテンツやスピナー — が生じます。SEOクローラーはレンダリングされたコンテンツの代わりにフォールバックHTMLを見ます。LighthouseはLCPとCLSスコアを引き下げます。クラッシュは発生しませんが、何かが間違ったタイミングで実行されたという明確なシグナルです。
よくある原因
- レンダリング中に
localStorageやsessionStorageを読み込む — これらはサーバー上に存在しないため、それらを読み込んで状態をセットするガードはマウント時、ちょうどハイドレーションが実行中のタイミングで発火します。 - サードパーティスクリプト(アナリティクス、チャットウィジェットなど)がページ読み込みの瞬間にグローバルコールバック経由で状態セッターを呼び出す場合。
- データフェッチライブラリ(SWR、React Query)がマウント時に再フェッチするよう設定されている場合 — セッターが、それを囲むSuspenseバウンダリのハイドレーション完了前に発火します。
- テーマや認証状態の初期化 — 最初のレンダリング時に
window.matchMediaやCookieを読み込み、それを状態に同期する場合。 - 並行モードのトランジション — バウンダリのハイドレーション完了前に開始された
startTransitionまたはuseTransitionの呼び出し。
デバッグ: 更新を発火させているものを特定する
まず、早期更新を引き起こしている正確なコンポーネントを特定します。コンソールロガーを持つ一時的な ErrorBoundary を使うと素早く特定できます:
// ErrorBoundary.jsx
import { Component } from 'react';
export class HydrationErrorBoundary extends Component {
componentDidCatch(error, info) {
console.error('Hydration error caught:', error);
console.error('Component stack:', info.componentStack);
}
render() {
return this.props.children;
}
}
一時的にSuspenseバウンダリをこれでラップします:
<HydrationErrorBoundary>
<Suspense fallback={<Spinner />}>
<YourComponent />
</Suspense>
</HydrationErrorBoundary>
componentStack が原因となっているコンポーネントを直接示します。Next.jsでは、このエラーと一緒に表示される2つ目の警告に注意してください: "A component suspended while responding to synchronous input."(同期入力への応答中にコンポーネントがサスペンドしました)。この2つはほぼ必ず一緒に現れ、どのバウンダリに問題があるかを確認するのに役立ちます。
解決策1: useEffect で状態更新を遅延させる
ブラウザAPIに依存する状態を useEffect に移動します。通常はこれだけで解決します。useEffect はSSR中には実行されず、ブラウザが描画した後にのみ実行されます — その時点でハイドレーションは完了しています。
// Before — レンダリング中に発火し、早期更新を引き起こす
const [theme, setTheme] = useState(
typeof window !== 'undefined'
? localStorage.getItem('theme') ?? 'light'
: 'light'
);
// After — ハイドレーション完了まで待機する
const [theme, setTheme] = useState('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
サーバーセーフなデフォルト値('light')で初期化し、エフェクト内で localStorage から同期します。Suspenseバウンダリはデフォルト値でハイドレーションを完了します。エフェクトはその後、バウンダリに影響を与えることなくクリーンに実行されます。
解決策2: 優先度の低い更新に startTransition を使用する
マウント時にフェッチが開始される場合、セッターを startTransition でラップすることで低優先度としてマークできます。React 18はこれらの更新をハイドレーション完了まで延期します。
import { startTransition, useState, useEffect } from 'react';
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(result => {
startTransition(() => {
setData(result); // 進行中のハイドレーションを中断しない
});
});
}, []);
return <div>{data}</div>;
}
解決策3: propsから派生する値に useDeferredValue を使用する
propの変更が更新を引き起こしている場合、useDeferredValue を使うとReactにハイドレーション完了まで古い値でレンダリングを続け、安定したら新しい値を適用するよう指示できます。
import { useDeferredValue } from 'react';
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
return (
<Suspense fallback={<Spinner />}>
<Results query={deferredQuery} />
</Suspense>
);
}
解決策4: 条件付きレンダリングに isMounted ガードを追加する
サーバー上で実行できないコンポーネントもあります — window を読み込んだり、DOMを操作したり、ブラウザ専用のAPIを呼び出すものです。それらを isMounted チェックの後ろにゲートして、ハイドレーション中にSuspenseサブツリーを静かに保ちます:
import { useState, useEffect } from 'react';
function ClientOnlyWidget({ children }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) return null;
return children;
}
// 使用例 — ハイドレーション中、このサブツリーからSuspenseバウンダリに更新が届かない
<Suspense fallback={<Skeleton />}>
<ClientOnlyWidget>
<ThirdPartyWidget />
</ClientOnlyWidget>
</Suspense>
解決策5: Next.js — ssr: false で動的インポートを使用する
Next.jsには、本質的にクライアント専用のコンポーネントのためのよりクリーンなエスケープハッチがあります。dynamic(..., { ssr: false }) はコンポーネントをSSRから完全に除外します — サーバーHTMLなし、ハイドレーションの試みなし、競合なしです。
import dynamic from 'next/dynamic';
const ClientOnlyMap = dynamic(() => import('./Map'), {
ssr: false,
loading: () => <div>Loading map...</div>,
});
// Suspenseの競合なし — このコンポーネントはサーバーレンダリングされない
export default function Page() {
return (
<div>
<ClientOnlyMap />
</div>
);
}
修正の確認
- Chrome DevToolsを開き → Consoleを確認します。エラーと関連する警告が消えているはずです。
- Network タブに切り替え、Slow 3Gにスロットリングして、ハードリロードします。コンテンツのフラッシュや短いスピナーが表示されないことを確認します — ハイドレーションがクリーンになれば、どちらも表示されないはずです。
npx react-devtoolsを実行して Profiler タブを確認します。クリーンなハイドレーションでは、Suspenseサブツリー内に予期しない再レンダリングのない単一の「コミット」フェーズが表示されます。- Next.jsでは、
next buildのターミナル出力を確認します。クリーンにハイドレーションされたページは、ログにハイドレーション警告なしで○(静的)またはλ(サーバー)としてレンダリングされます。
学んだこと
このエラーはReact 18特有のものです。なぜなら、並行レンダリングによってハイドレーションが中断可能になったからです。これが、データフェッチのための Suspense を動かす機能です。トレードオフとして: Reactはバウンダリのハイドレーション中に何が起きるかについて、より厳格になりました。
ハイドレーションはロックのように機能します。<Suspense> バウンダリはハイドレーション中にそれを保持します。ロックが解放される前に届く状態の更新は拒否され、クライアントの再レンダリングを強制します。useEffect、startTransition、useDeferredValue はすべて同じように機能します — ロックが解放されるまで待ってから実行します。
コンポーネントライブラリやデザインシステムを構築している場合は、レンダリング中にブラウザグローバルを読み込むコンポーネントを監査してください。このエラーが下流のアプリで発生したとき、最初に確認すべきものはそれらです。

