clock2021.12.13 09:05
SERVICE
home

React 18を受けて現状の SPA ルーティング設計を見直した

AUTHOR :   ギックス

3.5k

この記事は GiXo アドベントカレンダー の13日目の記事です。
昨日は「BigQueryスロット利用量の数え方・考え方」でした。

Technology Div. の堀越です。
React 18 は先月 Beta になりましたが、早ければ年始には Beta が外れそうですね。Suspense や Concurrent features(旧 Concurrent Mode)といった React SPA の設計に大きな変化をもたらす新機能はとても楽しみです。Concurrent features は部分的な適用が可能とのことで、移行の障壁がなくなったのもありがたいですね。

本記事ではそんな React 18 を受けて自分が担当するプロダクトの React SPA におけるルーティング設計を見直し中のため、その内容の一部をご紹介します。React 18 の詳細が明らかになっていく中で、開発中のプロダクトは React 18 の新機能を活かせる状態になっているか?と考えたとき、「なっていないな」と思いました。これは設計を見直す良い機会ということで、現状の設計の何が良くなかったかを振り返りつつ新機能を取り込むための設計変更を検討し始めた次第です。

なお、「これまでのルーティング設計は私たちにとってベストだったが React 18 によってそれが崩れた」のではなく、「実装を重ねる中で綻びが出ていたルーティング設計を React 18 という機会を得たので見直した」というのが正しいです。

現状の SPA ルーティング設計

ルーティングには react-router を使っています。react-router は React を書き始めた当初から使っているという理由で使い続けていますが、 「react-router v6 で頑張るなら Next.js や Remix に乗り換えてプラスアルファの恩恵を授かりたいなあ」とか「パスやクエリ文字列も型安全にしたいなあ」とか思わなくもない今日この頃です。それも現状の設計が枷になっているため段階を踏んで検討する必要があるのですが。

実際のページ数やルーティングのツリー構造の詳細は異なりますが、イメージとしては下図のようになっています。

図の各オブジェクトは1つのコンポーネントを表しており、簡単のために

  • <Route /> のみで UI なしの Routing Component(ルーター)
  • UI のみのコンポーネント(ページ)
  • UI とRoute の両方をもつコンポーネント(ページ & ルーター)

の3種類のみを図示しています(ページを構成する個々のコンポーネントは本記事ではあまり関係ないため省略)。

後述する実装イメージは詳細なロジックや State を除いた必要最低限の処理と JSX のみ記述しています(実際はどのコンポーネントも Container Component で何かしらのロジックや State はもっている)。また react-router v6 での記法になっていますが、これまで運用してきたのは react-router v5 です。一部 react-router v6 では同じ書き方ができない Route もありますが、本記事ではあくまで実装イメージとして載せているためご承知おきください。

現状のルーティング設計の難点

早速、現状の設計の問題と見直している点を書いていきます。

/ の Routing Component

/ の Routing Component はエントリーポイントで前準備を終えたあと最初に呼び出されるルーティング用のコンポーネントです。主にパスの1つ目の階層を宣言しています。

所感

このコンポーネントそのものについて書くことはあまりないのですが、ここに登場している <PagesRouter /> を作ったのはよくなかったです。理由は後述します。
react-router v6 で Outlet が登場したというのもありますが、少なくとも自分が担当するプロダクトについては Route を宣言するコンポーネントは1つに集約した方がよさそうだというのが設計を見直して至った結論のひとつです。そのため最終的には別のコンポーネントからこの階層に全ての Route を移す予定です。

上記はこの上なくシンプルですが、 <Page0 />, <Page1 />, <Page2 /> については Suspense でネストして lazy load しておけばなおよし、というところでしょうか。

/pages の Routing and Common Data Loading Component

前述した <PagesRouter /> コンポーネントにあたります。このコンポーネントは /pages 以下のページで必須となる共通データを取得しつつ /pages 以下の階層のルーティングを行っています。すなわち Route が宣言されている2つ目のコンポーネントです。実装イメージでは Route に加えて 1つのデータを fetch して処理しているのみですが、実際は複数のデータを fetch したり 読み込まれたデータの種類に応じて条件分岐したりと複数の処理が集まっています。

所感

このコンポーネントでは /pages 以下で必要となるデータのロードや加工処理を、/pages 以下の Route を宣言する前にまとめて行っています。この中には各ページ固有の処理も一部含まれており、それは特によろしくなかった部分です。実装当初は共通処理しかなかったのですが、ページ独自の詳細がこのコンポーネントに少し漏れ出てきてしまった結果です。
/pages 以下は少し複雑なルーティングの条件が存在するため、その処理をまとめるコンポーネントとして実装していたのですが、個々のページにあまり持ち込みたくない処理を置く便利屋さんのような立ち位置になってしまったのも反省点です。

メンテナビリティが低い

データをまとめて取得するコンポーネントを用意しておくと、「コンポーネント A でデータ A のロードが完了 → コンポーネント B をレンダリング → コンポーネント B でデータ B のロードが完了 → コンポーネント C をレンダリング、、」というウォーターフォール型のデータロードとレンダリングをせずに一括でリクエストを投げて取得したデータから表示する、ということが可能になります。一方で、「データ A が読み込まれたら コンポーネントA はレンダリングできるが、コンポーネント B は データ A とデータ B がないとレンダリングできない」といったコンポーネントの宣言や処理の分岐が集まるため実装が複雑になりがちです。また、データが読み込まれる順番がバラバラだと UI の表示にもばらつきが出て表示の滑らかさもユーザビリティも下がりますが、これを制御しようとするとさらに処理の複雑さが増します。

この例の Routing and Common Data Loading Component はまさにそうなっており、複数のデータ取得や State 更新が並行で走りつつ、それらの結果に応じた条件分岐が多数存在するコンポーネントです。/pages 以下は全てこのコンポーネントに依存しているため、処理の追加削除を行うと /pages 以下全てに影響があり手を加えにくいと感じるコンポーネントになってしまいました。よろしくありませんね。

どう実装すべきだったか

「共通データの取得や加工処理を行う Hook や関数を複数ページに重複して書きたくなかった」というのも子コンポーネントに代わってデータを取得・加工する親コンポーネントを実装した理由の1つでした。しかし、それにより不要な依存や複雑さを生んでしまったため、共通処理は Custom Hook に切り出して各コンポーネントに配置しつつ、処理の結果は Global な State で共有できる形にしておいた方がよかったかなと思いました。

Suspense を使えば各コンポーネントに実装されたデータのリクエストを並行して走らせつつ fallback でスケルトンやローディングを楽に表示できるため、「対象のコンポーネントで必要なデータの取得や加工はそのコンポーネントで行う」という設計に寄せた方がよさそうだという結論です。それならコンポーネント間の依存を最小限にしつつメンテナビリティを損なわずに済みます。

/pages/:id/piyo の With Routing コンポーネント

このコンポーネントは UI と Route の両方をもつページ兼ルーターで、実装としては下記のようなイメージです。

所感

react-router のメリットはファイルシステムベースのルーティングと違い、コンポーネント内でもプログマラブルに Route を設定できることだと思っていましたが、上記の実装イメージは適切な Route の使い方ができていなかったなあと思っています。

これはページの UI 設計側の事情ではありますが、/pages/:id/piyo の <Piyo /> と /pages/:id/piyo/foo の <Foo /> の UI は互いに共通部分がほとんどありません。それなのに、Foo コンポーネントを表示する際は <Piyo /> コンポーネントを先にレンダリングする必要があるため、無駄な処理が発生しています。
/pages/:id/piyo/foo のページ実装が react-router v6 の Outlet を使った下記のようになっていればよいのですが、そうではありませんでした。

リソースの定義から見れば間違ってはいないのですが、現状の UI 設計でこのルーティングにしているのはデメリットしかなさそうです。この設計では Code Splitting によるバンドルファイルのサイズ削減も適切に行えなくなります。

Concurrent features についてはどうか?

Concurrent features により実現可能となる Concurrent Rendering については自分がまだ勉強不足ですが、ルーティング設計というより UI 設計のよるところが大きいという認識です。

ルーティング設計はユーザーがコンテンツに辿り着くまでの導線を整えるイメージで、 Core Web Vitals で言えば First Contentful Paint のスコアに大きく関わってくる部分ですが、Concurrent Rendering はコンテンツに到達したあとの操作(体験)を損なわないための手段といったイメージだからです(First Input Delay に関わる部分)。

描画や何らかの処理負荷が高いページにおいてもユーザーからの入力に対するレスポンスを損なわない UI 設計というのは重要ですが、本記事でフォーカスしているルーティング設計について Concurrent Rendering の観点からこうするべき、というのは今のところ思いつきませんでした。

ルーティング設計を見直した結果

まだ修正している最中ですが、一旦の結論としては下記の通りです。

  1. ユーザーが到達したいコンテンツに辿り着くまでの処理を可能な限り減らす

    言葉にすると当たり前のことなのですが、それがなかなかどうしてうまくできていませんでした。今回の例でいうと /pages の Routing and Common Data Loading Component や /pages/:id/piyo の With Routing Component は /pages 以下の一部ページにとって不要な処理を発生させる原因となっていました。

  2. コンポーネントの描画に必要なデータの取得はそのデータに依存するコンポーネント自身で行う

    現状の設計で書いた通り、これまでは共通データを並行して取得・処理するための親コンポーネントを作って Route を宣言していました。しかしその実装が余計な依存と処理の複雑化を生んでしまったため、そうした親コンポーネントの実装は避けて Custom Hook に寄せることにしました。複数コンポーネントで同じ Custom Hook を呼び出すことになる点が少し気になりますが、ウォーターフォールでないデータの並行リクエストも Suspense によってやりやすくなったため、この方針で実装を進めています。

  3. 親リソースと子リソースで共通の UI がないなら Route はネストしない

    これまで Route は一階層ずつネストしていくのが普通だと思い込んでいたのですが、よくよく考えると親リソースと子リソースで共通のコンポーネントがないならネストする意味ないのでは?と思いました。親リソースと子リソースで共通のコンポーネントがないのは、そもそものリソース設計をミスっているのかもと思わなくもないのですが。ざっと見直した限りではそんなこともなさそうなので、とりあえずは「ユーザーが目的とするコンテンツに最短で辿り着けるようにする」という観点で Route に設定するパス設計もやり直しています。

  4. Route の宣言を行うコンポーネントは1つに集約する

    今回実装を見直した限りでは、対象プロダクトについて Route の宣言を複数コンポーネントで行うメリットは見つかりませんでした。ルーティングの責務を1つのルーターにまとめられるならそれに越したことはないと思うため、そのように修正を進めています。

上記の結論を踏まえた実装の修正はまだ進めている最中ですが、最終的には下記のようになる想定です。

元の設計と比較しやすいように各ページコンポーネントの位置はそのままにしていますが、全ての Route を / の Routing Component で宣言する形にしました。これにより、これまでと比べて格段に Code Splitting やページ単位での実装の切り分けがやりやすい形になったと感じています。
逆に、これまでの設計は Code Splitting やページ間の疎結合をあまり意識できていなかったということなのですが、実際に作って動かしたからわかったことも多くあるため結果オーライでひとつずつ改善していければと思います。

上の図では Route のネストが表現できていないため、JSX での実装イメージも載せておきます。

各ページ共通のデータ取得・加工やルーティングのロジックは Custom Hooks か各ページ固有のロジックとして各コンポーネントに閉じ込める予定です。(Global State の管理には Redux を使っています)

おわりに

本記事では React SPA のルーティング設計を見直す中で考えた内容を紹介させていただきました。
GiXo のプロダクトのフロントエンド開発に少しでも興味をもっていただけたら幸いです。もう少し詳細を知りたい方がいましたらぜひお問い合わせください。採用についてはカジュアル面談も実施しています。採用情報はこちら

明日は Business Planning Div. の竹内より、「Business Planning Div. 紹介」を公開予定です。


Go Horikoshi
Data-Informed 事業本部 / Technology Div. 所属
フロントエンドエンジニア。React, TypeScript を中心とした Web フロントエンドについて発信していきます。最近の興味はサーバーサイド JavaScript(エッジや Service Worker)とコード生成による開発の効率化です。

SERVICE