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を組み立ててブラウザに送るためのもので、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の型付けパターンについて整理する予定です。

Reactを基礎から学び直す #4: 状態管理の整理とuseReducerの実践

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

前回は、useStateとuseEffectの実践的な使い方、特にESLintのルールをきっかけに設計を見直した話を書きました。

今回は、複数の画面をまたぐStateをどう管理するか、RE:FITで実際に採用したuseReducerを中心に整理します。

診断アプリのように画面数が増えてくると、useStateだけではStateの管理がだんだん苦しくなってくる、という話です。

画面が増えるとuseStateだけでは辛くなる

RE:FITの診断UIは、開始画面・4つの質問・結果画面・予約・確認・完了画面という、全部で9つのステップで構成されています。

それぞれの画面は、「今どのステップにいるか」「質問への回答」「予約の入力内容」という3種類のStateを共有しながら動いています。

最初のうちは、これらを1つずつuseStateで管理しようとしていました。

BEFORE(画面ごとにuseStateを並べる)

const [step, setStep] = useState("start");
const [goal, setGoal] = useState(null);
const [experience, setExperience] = useState(null);
const [frequency, setFrequency] = useState(null);
const [concerns, setConcerns] = useState([]);
const [date, setDate] = useState(null);
const [time, setTime] = useState(null);

質問が1つ増えるたびに、useStateもセットで増えていきます。

さらに「次のステップに進む」という単純な操作のために、setStepと関連するsetXxx系の関数を、あちこちのコンポーネントで呼ぶ必要が出てきました。

これでは、どの操作がどのStateを変えるのか、コードを読むだけでは追いにくくなってしまいます。

useReducerでStateの変化を1箇所にまとめる

そこで導入したのが、useReducerです。

useReducerは、「現在のState」と「actionという名前の操作」を受け取って「次のState」を返す、reducerという1つの関数にStateの変化をまとめて任せるHookです。

RE:FITでは、Stateの形とreducer関数を、こう定義しています。

AFTER

type DiagnosisState = {
  step: Step;
  answers: Answers;
  booking: BookingInfo;
};

type DiagnosisAction =
  | { type: "START" }
  | { type: "SET_GOAL"; goal: Goal }
  | { type: "TOGGLE_CONCERN"; concern: Concern }
  | { type: "NEXT" }
  | { type: "BACK" }
  | { type: "SUBMIT_BOOKING" };

const diagnosisReducer = (state: DiagnosisState, action: DiagnosisAction): DiagnosisState => {
  switch (action.type) {
    case "START":
      return { ...state, step: "q-goal" };

    case "SET_GOAL":
      return {
        ...state,
        answers: { ...state.answers, goal: action.goal },
        step: "q-experience",
      };

    case "TOGGLE_CONCERN": {
      const has = state.answers.concerns.includes(action.concern);
      const concerns = has
        ? state.answers.concerns.filter((c) => c !== action.concern)
        : [...state.answers.concerns, action.concern];
      return { ...state, answers: { ...state.answers, concerns } };
    }

    default:
      return state;
  }
};

コンポーネント側は、Stateを直接書き換えるのではなく、「何が起きたか」を表すactiondispatchするだけになります。

const [state, dispatch] = useReducer(diagnosisReducer, initialState);
const { step, answers, booking } = state;

dispatch({ type: "SET_GOAL", goal });

dispatch({ type: "NEXT" });

useReducerに変えてよかったこと

1つ目は、Stateが変化するロジックがすべてdiagnosisReducerの中に集まったことです。

「このactionが来たら何が起きるか」を、コンポーネントを1つずつ追わなくても、reducer関数を読むだけで把握できるようになりました。

2つ目は、reducerが純粋関数であることのメリットです。

画面を描画しなくても、Stateとactionを渡すだけでテストできるので、動作確認がぐっと楽になったんです。

3つ目は、コンポーネント側の関心が「何が起きたかをdispatchすること」だけに減った点です。

Stateの中身がどう変わるかを、画面側のコードは知らなくてよくなりました。

useReducerを使うかどうかの判断基準

とはいえ、なんでもかんでもuseReducerにすればいいわけではないとも感じています。

Stateの数が少なく、更新のロジックも単純なら、useStateで十分だと思います。

逆に、複数のStateが連動して変化する、更新のパターンがactionとして列挙できる、という条件が揃ってきたらuseReducerを検討する、というのが今のところの自分の判断基準です。

まとめ

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

個々のsetStateがあちこちに散らばっていく感覚から、actionreducerで整理された感覚に変わったのが、自分でも大きな収穫でした。

次回は、Next.jsとの組み合わせ方、特にApp Routerでのレンダリング戦略について整理します。

Reactを基礎から学び直す #3: Hooksの基礎(useState/useEffect)と実践的な使い方

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

前回は、PropsとStateの役割の違いを整理しました。

今回は、Stateを実際に扱うためのHooks、useStateuseEffectの実践的な使い方を書きます。

RE:FITの開発中に、ESLintの新しいルールに引っかかって設計を見直した実話もあわせて紹介します。

useStateの基本と遅延初期化

useStateは、コンポーネントの中でStateを持つための最も基本的なHookです。

RE:FITの予約カレンダー画面では、こう使っています。

const [calendar] = useState<CalendarDay[]>(() =>
  generateCalendar(new Date())
);

ポイントは、初期値をgenerateCalendar(new Date())と直接書かず、() => generateCalendar(new Date())という関数(遅延初期化)にしていることです。

初期値をそのまま書くと、再レンダーのたびに計算が走ってしまいますが、関数で渡すと初回のレンダー時にしか実行されません。

カレンダーの生成のように多少コストのある処理を初期値にするときは、この書き方が安全だと学びました。

useEffectとは何か

useEffectは、レンダーが終わったあとに副作用(外部とのやり取り)を実行するためのHookです。

データの取得や、DOM操作、外部ライブラリとの連携などで使われます。

RE:FITでも最初、予約カレンダー画面でこのuseEffectを使っていました。

つまずいた話:ESLintに怒られた

もともとのBookingScreenは、こう書いていました。

BEFORE(最初に書いたコード)

const [calendar, setCalendar] = useState<CalendarDay[] | null>(null);

useEffect(() => {
  setCalendar(generateCalendar(new Date()));
}, []);

サーバー側でレンダーされる時刻とブラウザ側の時刻がズレると、日付ベースのカレンダー表示が食い違ってhydrationエラーになる可能性があります。

そこで、マウント後(=ブラウザ側)にだけカレンダーを生成する、という意図でこう書いていました。

ところがnpm run lintを実行すると、こんなエラーが出ました。

Calling setState() directly within an effect
react-hooks/set-state-in-effect

ESLintのreact-hooksプラグインが持つ比較的新しいルールで、「エフェクトの中で直接setStateを呼ぶと、レンダーの連鎖が起きて性能が落ちる可能性がある」という指摘でした。

なぜuseEffectを使わずに済んだか

エラーメッセージを読みながら考えて、そもそもこのアプリ全体はサーバーでレンダーする必要がないと気づきました。

RE:FITは診断から予約までを完結させるインタラクティブなミニアプリで、SEOのために事前レンダーしたいコンテンツがあるわけではありません。

そこで、アプリ全体を読み込む部分だけを、サーバーサイドレンダリングを無効化したコンポーネントに切り出しました。

AFTER

const DiagnosisApp = dynamic(() => import("./DiagnosisApp"), {
  ssr: false,
});
const [calendar] = useState<CalendarDay[]>(() =>
  generateCalendar(new Date())
);

サーバーで描画されなくなったことで、hydrationのズレという前提条件自体がなくなり、useEffectを使わずに冒頭で紹介した遅延初期化だけで安全に書けるようになりました。

useEffectを使わずに済ませる方法を探した結果、コードもシンプルになったので、結果的に良い設計変更になったと思っています。

Hooksを使い分ける基準

この経験から、自分の中でのHooksの使い分け基準が整理できました。

初期値の計算やStateの保持はuseState(重い処理は遅延初期化で)、外部システムとの同期がどうしても必要なときだけuseEffect、という順番で考えるようにしています。

「なんとなくuseEffectで書けば動く」ではなく、まず本当にuseEffectが必要かを疑う、というのが今回いちばんの学びでした。

まとめ

今回は、useStateの遅延初期化と、useEffectまわりで実際につまずいた経験を書きました。

ESLintの新しいルールに指摘されたことがきっかけで、アプリ全体の設計を見直すことになったのは、自分でも意外な展開でした。

次回は、複数の画面をまたぐStateをどう管理するか、RE:FITで採用したuseReducerを中心に整理します。

Reactを基礎から学び直す #2: コンポーネント設計の基礎(関数コンポーネント・Props・State)

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

前回は、Reactを基礎から学び直すことにした理由を書きました。

今回からは実際に基礎を整理していきます。

まずはシリーズの土台になる、コンポーネント・Props・Stateの3つです。

そもそも「コンポーネント」とは

Reactにおけるコンポーネントは、UIの一部を返す関数のことです。

以前はクラスで書くこともありましたが、今はほとんどの場面で関数コンポーネントを使います。

RE:FITでも、診断の各質問画面をそれぞれ1つのコンポーネントとして分けて実装しました。

たとえば「トレーニングの目的は?」という最初の質問は、GoalQuestionという1つのコンポーネントです。

Props:親から子へデータを渡す仕組み

Propsは、親のコンポーネントから子のコンポーネントに値を渡すための仕組みです。

GoalQuestionのコードを見てみます。

type Props = {
  value: Goal | null;
  onSelect: (goal: Goal) => void;
  onBack: () => void;
};

export default function GoalQuestion({ value, onSelect, onBack }: Props) {
  // ...
}

value(今選ばれている目的)、onSelect(選択されたときの処理)、onBack(戻るときの処理)の3つを、親のコンポーネントから受け取っています。

ポイントは、GoalQuestion自身はこれらの値を書き換えられないということです。

選択された、という事実をonSelect経由で親に伝えるだけで、実際に値を更新するのは親側の役目になります。

このときは何となく「そういうものだ」で書いていたのですが、あらためて整理すると、子コンポーネントは親から預かったデータを表示・通知するだけの窓口だと考えると腑に落ちました。

State:コンポーネントが自分で持つ値

一方でStateは、コンポーネント自身が管理する値です。

RE:FITの予約カレンダー画面BookingScreenでは、カレンダーのデータをコンポーネント内のStateとして持たせています。

const [calendar] = useState<CalendarDay[]>(() =>
  generateCalendar(new Date())
);

このカレンダーのデータは、診断結果や予約日時のように他のコンポーネントと共有する必要がありません。

BookingScreenが表示されるたびに生成すればいい、その場限りのデータです。

だからこそ、Propsで親から受け取るのではなく、コンポーネント自身のStateとして持たせています。

PropsとStateの境界線をどう引いたか

書きながら気づいたのは、「このデータは誰が持つべきか」を都度考える必要があるということです。

RE:FITでは、診断の回答や予約日時のように画面をまたいで使うデータは、個々のコンポーネントのStateではなく、後述する仕組みで一箇所にまとめて管理しています。

反対に、BookingScreenのカレンダーのように、その画面の中だけで完結するデータはコンポーネント自身のStateに閉じています。

「複数の画面で必要か、その場限りか」が、PropsとStateはもちろん、State自体をどこに置くかを判断する基準になっていました。

この「複数の画面をまたぐ状態をどう管理するか」は、シリーズ第4回であらためて詳しく書く予定です。

まとめ

今回は、コンポーネント・Props・Stateという3つの基礎を、RE:FITの実際のコードを見ながら整理しました。

Propsは親から子へのデータの受け渡し、Stateはコンポーネント自身が持つ値、という役割の違いがポイントです。

次回は、Stateを実際に扱うためのHooks、特にuseStateuseEffectの実践的な使い方を書きます。

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)から始めます。