🦔

React Client Component の二重実行とハイドレーションエラー対策

に公開

React Server Components (RSC) を使用する際、多くの開発者が「Client Component はクライアントでのみ実行される」と誤解しがちです。
しかし実際には、Client Component はサーバーとクライアントの両方で実行されるという重要な特性があります。

この二重実行により、サーバーとクライアントで異なる値が生成される場合(時刻、ランダム値、ブラウザAPIなど)、ハイドレーションエラーが発生します。
この記事では、この問題の原因と具体的な解決方法を解説します。

結論

Client Component はサーバ側とクライアント側で2回実行される
Client Component = クライアントでのみ実行されるコンポーネントではない

検証

こんなClient Componentを作ってみる

'use client';
export default function ClientComponent() {
  console.log('実行されました!', new Date().toISOString());
  return <p>現在時刻: {new Date().toISOString()}</p>;
}

実行タイミング
このコンポーネントが実行されるタイミングは以下となる。

  • サーバー実行 (SSR)
    • 初期HTML生成
  • クライアント実行 (ハイドレーション)
    • インタラクティブ機能追加

コンソール出力

[サーバー] 実行されました! 2025-06-07T10:30:45.123Z
[ブラウザ] 実行されました! 2025-06-07T10:30:45.891Z

二重実行の詳細プロセス

1. サーバーでの実行 (Pre-rendering)

// Node.js環境で実行
'use client';
export default function TimeComponent() {
  // サーバーの時刻で実行される
  const time = new Date().toISOString(); // "2025-06-07T10:30:45.123Z"
  return <p>{time}</p>;
}

生成されるHTML

<p>2025-06-07T10:30:45.123Z</p>

2. クライアントでの実行 (Hydration)

// ブラウザ環境で実行(同じコード)
'use client';
export default function TimeComponent() {
  // ブラウザの時刻で実行される
  const time = new Date().toISOString(); // "2025-06-07T10:30:45.891Z"
  return <p>{time}</p>;
}

生成されるHTML

<p>2025-06-07T10:30:45.891Z</p> <!-- サーバーと違う! -->

そしてハイドレーションエラーの発生😅

Warning: Text content did not match. 
Server: "2025-06-07T10:30:45.123Z" 
Client: "2025-06-07T10:30:45.891Z"

Error: Hydration failed because the initial UI does not match 
what was rendered on the server.

なぜ二重実行が必要なのか?🤔

SSR (Server-Side Rendering) の利点

// Server Component
export default function Page() {
  return (
    <div>
      <h1>ブログ記事</h1>
      <ClientInteractiveButton /> {/* これも初期HTMLに含まれる */}
    </div>
  );
}

// Client Component
'use client';
export default function ClientInteractiveButton() {
  const [liked, setLiked] = useState(false);
  
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'} いいね
    </button>
  );
}

ユーザー体験の流れ

  1. 0ms: サーバーからHTML受信
<div>
  <h1>ブログ記事</h1>
  <button>🤍 いいね</button> <!-- 見えるけどクリックできない -->
</div>
  1. 1000ms: JavaScriptダウンロード完了
  2. 1100ms: ハイドレーション完了
<button onclick="...">🤍 いいね</button> <!-- クリック可能になった! -->

use clientの真の意味

'use client'; // これは「指示」であり「制限」ではない

実際の意味:
✅ "このコンポーネントをクライアントバンドルに含めて"
✅ "ブラウザでインタラクティブにして"
✅ "でも、SSRのためにサーバーでも実行して"
❌ "クライアントでのみ実行して" ← これは間違い

問題が発生するパターン

1. 時間依存の値

// ❌ 問題のあるコード
'use client';
export default function CurrentTime() {
  return <p>現在時刻: {new Date().toISOString()}</p>;
}

2. ランダム値

// ❌ 問題のあるコード
'use client';
export default function RandomNumber() {
  return <p>ランダム: {Math.random()}</p>;
}

3. ブラウザAPI依存

// ❌ 問題のあるコード
'use client';
export default function WindowSize() {
  return <p>画面幅: {window.innerWidth}px</p>; // サーバーでwindowは存在しない
}

4. ユーザー固有データ

// ❌ 問題のあるコード
'use client';
export default function UserPreference() {
  const theme = localStorage.getItem('theme'); // サーバーでlocalStorageは存在しない
  return <p>テーマ: {theme}</p>;
}

解決方法

1. useEffect を使用する方法

useEffectを使用することで、遅延実行を行います。ハイドレーション実行時のtimeの値はnullとなり、生成されるHTMLが一致します。これにより、ハイドレーションエラーが発生しません。

'use client';
import { useState, useEffect } from 'react';

export default function SafeCurrentTime() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    // この部分は本当にクライアントでのみ実行される
    setTime(new Date().toISOString());
  }, []);

  // サーバー実行時とハイドレーション時は null
  if (time === null) {
    return <div>時刻読み込み中...</div>; // プレースホルダー
  }

  // クライアントでの状態更新後のみ実際の時刻表示
  return <p>現在時刻: {time}</p>;
}

実行フロー

  1. サーバー: time = null → "時刻読み込み中..." 表示
  2. 初回ハイドレーション: time = null → "時刻読み込み中..." 表示(一致✅)
  3. useEffect実行: setTime() → 再レンダリング → 実際の時刻表示

2. useSyncExternalStoreを使用する方法

useSyncExternalStoreの第3引数getServerSnapshotは、サーバーレンダリング時とハイドレーション時にのみ呼び出される特性を利用した方法です。この仕組みを活用することで、サーバーとクライアントで異なる値を返しながらも、ハイドレーションエラーを回避できます。

本来このフックは外部ストアとの同期のために設計されましたが、環境判定においてはこの特殊な実行タイミングを「ハック的」に利用します。

'use client';
import { useSyncExternalStore } from 'react';

export function useIsClient() {
  return useSyncExternalStore(
    () => () => {},  // subscribe: 空の関数
    () => true,      // getSnapshot: クライアント実行時
    () => false      // getServerSnapshot: サーバー実行時
  );
}
'use client';
export default function SmartCurrentTime() {
  const isClient = useIsClient();

  if (!isClient) {
    return <div>--:--:--</div>; // サーバー用プレースホルダー
  }

  return <p>現在時刻: {new Date().toISOString()}</p>; // クライアントでのみ実行
}
  1. サーバー: getServerSnapshot() → false → "--:--:--" 表示
  2. 初回ハイドレーション: getServerSnapshot() → false → "--:--:--" 表示(一致✅)
  3. ハイドレーション完了: getSnapshot() → true → 実際の時刻表示

まとめ

Client Component は「クライアント専用」ではなく、「サーバーとクライアントの両方で実行される特別なコンポーネント」です。
この二重実行の性質を理解し、適切に対処することで、

  • ハイドレーションエラーを防げる
  • SSRの恩恵を受けながらインタラクティブ機能を提供できる
  • ユーザー体験を最適化できる

ということができます。

Discussion