← ブログ一覧に戻る

Reactを基礎から学び直す #5: Next.jsとの組み合わせ方(App Router)

2026.07.29

フリーランスでWeb制作をしている矢野です。

前回は、複数の画面をまたぐStateの管理を、useStateからuseReducerに切り替えた話を書きました。

今回は、そのuseReducerを使った診断UIを、実際にどうNext.jsのApp Routerに組み込んだかを整理します。

サーバーコンポーネントとクライアントコンポーネントの境界の引き方、それからRE:FITの開発中に実際に遭遇したhydrationエラーの話です。

App Routerではコンポーネントがデフォルトでサーバーコンポーネントになる

App Routerでは、appディレクトリの中に置いたコンポーネントは、何も書かなければサーバーコンポーネントとして扱われます。

サーバーコンポーネントは、サーバー側でHTMLを組み立ててブラウザに送るためのもので、useStateuseReducerのようなブラウザ側のStateを持つことができません。

RE:FITのトップページ、app/page.tsxも、実はサーバーコンポーネントのままです。

// app/page.tsx(サーバーコンポーネント)
import DiagnosisAppLoader from "@/components/DiagnosisAppLoader";

export default function Home() {
  return (
    <main className="rf-app">
      <DiagnosisAppLoader />
    </main>
  );
}

けれど、診断UIの中身は、質問に答えるたびにdispatchでStateを更新する、がっつりインタラクティブな部分です。

そこで、page.tsxから呼び出しているDiagnosisAppLoaderというコンポーネントの先頭に"use client"を書いて、そこから先だけをクライアントコンポーネントの境界にしています。

サーバーとクライアントで今日の日付がズレて、hydrationエラーになった

ここでもう一つ、別の問題が起きました。

予約カレンダー画面は、new Date()を基準にして、14日分の空き枠を生成しています。

export function generateCalendar(referenceDate: Date, days = 14): CalendarDay[] {
  const result: CalendarDay[] = [];
  for (let i = 1; i <= days; i++) {
    const d = new Date(referenceDate);
    d.setDate(d.getDate() + i);
    // ...この後、dを基準に空き枠を計算する
  }
  return result;
}

サーバーがHTMLを組み立てた瞬間の時刻と、ブラウザがそれを受け取ってHydration(貼り付け)する瞬間の時刻は、実はミリ秒単位でズレることがあります。

日付が変わる瞬間にアクセスされると、サーバー側のnew Date()とクライアント側のnew Date()が違う日を指してしまい、生成されるカレンダーの中身が食い違って、Reactの「Hydration failed」というエラーが出てしまったんです。

dynamic importでSSRを無効化して解決した

BEFORE(DiagnosisAppを直接インポートしていた)

// app/page.tsx(サーバーコンポーネント)
import DiagnosisApp from "@/components/DiagnosisApp";

export default function Home() {
  return (
    <main className="rf-app">
      <DiagnosisApp />
    </main>
  );
}

最初はこう、DiagnosisApppage.tsxから直接読み込んでいました。

これだと、DiagnosisApp自体は"use client"なコンポーネントでも、Next.jsは初回表示用のHTMLをサーバー側でも生成しようとします。

その結果、サーバー側の描画とクライアント側の描画で、カレンダーの中身が食い違う余地が生まれてしまいます。

AFTER

// components/DiagnosisAppLoader.tsx
"use client";

import dynamic from "next/dynamic";

const DiagnosisApp = dynamic(() => import("./DiagnosisApp"), {
  ssr: false,
  loading: () => <div className="rf-card rf-card--loading">読み込み中…</div>,
});

export default function DiagnosisAppLoader() {
  return <DiagnosisApp />;
}
// app/page.tsx(サーバーコンポーネントのまま)
import DiagnosisAppLoader from "@/components/DiagnosisAppLoader";

export default function Home() {
  return (
    <main className="rf-app">
      <DiagnosisAppLoader />
    </main>
  );
}

next/dynamicssr: falseオプションを使うと、そのコンポーネントをサーバー側では一切描画せず、ブラウザ側だけでレンダリングするようになります。

サーバー側にHTMLが存在しなければ比較のしようがないので、hydrationのズレそのものが起きなくなる、というわけです。

ちなみにssr: falseは、App Routerではサーバーコンポーネントの中から直接呼び出すことができず、"use client"なコンポーネントの中でしか使えません。

DiagnosisAppLoaderという薄いラッパーを用意しているのは、このルールに合わせるためでもあります。

レンダリング戦略を使い分けて学んだこと

今回の経験で、「とりあえず全部"use client"にする」のは避けたほうがいいと実感しました。

page.tsx自体はサーバーコンポーネントのままにして、インタラクティブな部分だけをクライアントコンポーネントとして切り出す、という境界の引き方のほうが見通しがよくなります。

また、new Date()Math.random()のような「実行するたびに結果が変わる処理」は、サーバーとクライアントの両方で描画されるコンポーネントの中に置くと、hydrationエラーの原因になりやすいということも学びました。

対策としては、値を決定的にする、suppressHydrationWarningを使う、今回のようにssr: falseでサーバー側の描画自体をやめる、といった選択肢があります。

RE:FITの予約カレンダーのように「今日」を基準にした計算がどうしても必要な場合は、ssr: falseで丸ごとクライアント側に寄せるのがいちばん単純で確実でした。

まとめ

今回は、RE:FITの診断アプリをApp Routerに組み込む中で、サーバーコンポーネントとクライアントコンポーネントの境界の引き方と、hydrationエラーの原因・解決を書きました。

「サーバーで描いた結果」と「クライアントで描いた結果」が一致している必要がある、というReactの前提を、実際にエラーを踏んで初めて体で理解できた気がします。

次回は、TypeScriptとReactの型付けパターンについて整理する予定です。