Reactを基礎から学び直す #5: Next.jsとの組み合わせ方(App Router)
フリーランスで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の型付けパターンについて整理する予定です。
Reactを基礎から学び直す #1: なぜ今、学び直すことにしたのか
フリーランスでWeb制作をしている矢野です。
普段の私は、本業ではチャット型LPのReact実装やCVR改善を担当し、副業ではWordPressでのコーポレートサイト制作を中心にしています。
Reactは仕事で毎日触っているので、動くコードは書けます。
ただ正直に言うと、「なぜこの設計にしたのか」を基礎から筋道立てて人に説明できるかというと、あまり自信がありませんでした。
普段の私とReactの距離感
本業ではすでに動いているプロダクトの改修が中心なので、既存のコンポーネント構成やHooksの使い方に沿って実装することがほとんどです。
一方で副業のWeb制作はWordPressでのコーポレートサイト制作が中心なので、Reactを「ゼロから設計する」機会自体があまりありませんでした。
その結果、Reactを使えてはいるものの、Props・State・Hooksあたりの基礎を体系立てて説明する自信は正直なかったのです。
きっかけになった、ポートフォリオ4作品目
そんな状態を自覚したのが、ポートフォリオの4作品目として、React・Next.js製の診断アプリ「RE:FIT」を作ったときでした。
パーソナルトレーニングジムを想定し、目的・運動経験・通える頻度を答えると、その場でおすすめプランを診断して体験予約まで進める、というミニアプリです。
診断の質問から結果、予約までの状態を管理する部分で、useStateを並べるだけでは分岐が追いきれなくなり、useReducerで状態遷移を1箇所にまとめる設計にしました。
type DiagnosisAction =
| { type: "SET_GOAL"; goal: Goal }
| { type: "NEXT" }
| { type: "BACK" }
| { type: "RESTART" };
書きながら「この設計にした理由」は自分の中では明確だったのですが、いざ言語化しようとすると、なぜuseStateの複数管理ではなくuseReducerを選んだのかを、基礎から順序立てて説明できる言葉がすぐには出てきませんでした。
動かせることと、人に説明できることは別だと痛感した瞬間です。
「なんとなく動く」と「人に説明できる」の差
この差は、本業でも副業でも地味に効いてきます。
本業でチームに設計意図を伝えるときも、副業でクライアントに技術的な判断根拠を説明するときも、求められているのは「動くコード」そのものより「なぜそう作ったかを言語化する力」だからです。
裏を返せば、基礎から体系的に学び直す過程を発信すること自体が、初心者にも分かりやすく説明できる実務力の証明になるはずだと考えました。
このシリーズでやること
そこで、Reactを基礎から学び直す過程を、そのまま記事シリーズとして公開していくことにしました。
全7回を予定していて、コンポーネント設計の基礎、Hooksの実践的な使い方、状態管理の整理、Next.jsとの組み合わせ方、TypeScriptでの型付けと進めていきます。
最終回では、学び直した基礎を踏まえて、今回作った「RE:FIT」の実装をあらためて振り返る予定です。
自分自身の理解を体系立て直すログが、同時にポートフォリオの技術力を伝えるコンテンツにもなる、という一石二鳥を狙っています。
まとめ
今回は、Reactを基礎から学び直すことにした理由と、このシリーズの狙いを書きました。
きっかけは、実際に作った診断アプリの状態管理を人に説明しようとして、うまく言葉にできなかった経験です。
次回は、シリーズの土台となるコンポーネント設計の基礎(関数コンポーネント・Props・State)から始めます。