フリーランスでWeb制作をしている矢野です。
前回は、複数の画面をまたぐStateの管理を、useStateからuseReducerに切り替えた話を書きました。
今回は、そのuseReducerを使った診断UIを、実際にどうNext.jsのApp Routerに組み込んだかを整理します。
サーバーコンポーネントとクライアントコンポーネントの境界の引き方、それからRE:FITの開発中に実際に遭遇したhydrationエラーの話です。
App Routerではコンポーネントがデフォルトでサーバーコンポーネントになる
App Routerでは、appディレクトリの中に置いたコンポーネントは、何も書かなければサーバーコンポーネントとして扱われます。
サーバーコンポーネントは、サーバー側でHTMLを組み立ててブラウザに送るためのもので、useStateやuseReducerのようなブラウザ側の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>
);
}
最初はこう、DiagnosisAppをpage.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/dynamicのssr: 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の型付けパターンについて整理する予定です。