React のベストプラクティスを再考する#
十数年前、React はクライアントサイドレンダリングのシングルページアプリケーションのベストプラクティスを再考しました。
現在、React の採用率はピークに達し、健全な批判と疑問を受け続けています。
React 18 と React サーバーコンポーネント(RSCs)は、最初の「ビュー」クライアント MVC のラベルラインから重要な段階への移行を示しています。
この記事では、React が React ライブラリから React アーキテクチャへと進化する過程を理解しようとします。
アンナ・カレーニナの原則は、「すべての幸せな家庭は似ているが、不幸な家庭はそれぞれの方法で不幸である」と述べています。
私たちは、React のコア制約とそれを管理するための過去の方法を理解することから始めます。幸せな React アプリケーションを団結させる基本的なパターンと原則を探ります。
最後には、Remix や Next 13 アプリケーションディレクトリのような React フレームワークにおける変化するメンタルモデルを理解します。
これまで解決しようとしてきた潜在的な問題を理解することから始めましょう。これにより、サーバー、クライアント、バンドラー間の緊密な統合を持つ高級フレームワークを活用するという React コアチームの提案を文脈に置くことができます。
何の問題を解決しているのか?#
ソフトウェアエンジニアリングには、通常、技術的な問題と人間関係の問題の 2 つのカテゴリがあります。
アーキテクチャは、時間の経過とともにこれらの問題を解決するための適切な制約を見つけるプロセスと見なすことができます。
人間関係の問題を解決するための適切な制約がなければ、人々が協力すればするほど、時間の経過とともに変更の複雑性、耐障害性、リスクが増大します。技術的な問題を管理するための適切な制約がなければ、公開するコンテンツが多ければ多いほど、最終的なユーザー体験は通常悪化します。
これらの制約は、複雑なシステムの中で構築し、相互作用する人間として直面する最大の制限、すなわち限られた時間と注意を管理するのに役立ちます。
React と人間関係の問題#
人間関係の問題を解決することは高いレバレッジを持っています。限られた時間と注意の中で、個人、チーム、組織の生産性を向上させることができます。
チームの時間とリソースは限られており、迅速に納品する必要があります。個人として、私たちの脳の容量は限られており、大量の複雑性を抱えることはできません。
私たちはほとんどの時間を現状を理解し、どのように最善の変更を行うか、または新しいコンテンツを追加するかを考えています。人々は、全体のシステムを完全に頭に入れることなく操作できる必要があります。
React の成功は、当時の既存のソリューションと比較して、この制約を管理する能力に大きく起因しています。これにより、チームは分担して並行して疎結合のコンポーネントを構築し、これらのコンポーネントを宣言的に組み合わせ、単方向データフローで「スムーズに動作」させることができました。
そのコンポーネントモデルとエスケープハッチは、明確な境界内でレガシーシステムと統合の混乱を抽象化することを可能にしました。しかし、この疎結合とコンポーネントモデルの影響の一つは、木を見て森を見失うことが容易であるということです。
React と技術的問題#
React は、当時の既存のソリューションと比較して、複雑なインタラクション機能を実装するプロセスを簡素化しました。
その宣言的モデルは、react-dom のような特定のプラットフォームのレンダラーに入力される n-ary ツリーのデータ構造を生成します。チームを拡大し、既製のパッケージを求めるにつれて、このツリー構造はすぐに非常に深くなります。
2016 年のリライト以来、React はエンドユーザーのハードウェア上で大規模で深いツリーを処理する技術的問題に積極的に取り組んできました。
オンラインでは、画面の向こう側で、ユーザーの時間と注意も限られています。期待値は上昇し、注意の幅は狭まっています。ユーザーはフレームワーク、レンダリングアーキテクチャ、または状態管理に関心を持っていません。彼らは摩擦なく完了すべきタスクを完了したいと考えています。もう一つの制約は、迅速に行動し、ユーザーに考えさせないことです。
次世代の React(および React スタイル)フレームワークで推奨される多くのベストプラクティスは、純粋にクライアント CPU 上で深いツリーを処理することから生じる影響を軽減しています。
偉大な分断を振り返る#
これまでのところ、テクノロジー業界は、サービスの集中化と分散化、薄いクライアントと厚いクライアントなど、異なる軸で揺れ動いています。
私たちは、厚いデスクトップクライアントから、Web の台頭に伴いますます薄くなり、モバイルコンピューティングと SPA の台頭に伴い再び厚くなるクライアントに揺れ動いてきました。現在、React の支配的なメンタルモデルは、この厚いクライアントアプローチに根ざしています。
この変化は、「フロントエンドのフロントエンド」開発者(CSS、インタラクションデザイン、HTML、アクセシビリティパターンに精通している)と「フロントエンドのバックエンド」間で分断を生み出しました。私たちはフロントエンドとバックエンドの分離の過程でクライアントに移行しました。
React エコシステムでは、これら二つの世界のベストプラクティスを調和させようとする中で、揺れ動く方向が再び中間地点に戻ってきています。その中で多くの「フロントエンドのバックエンド」スタイルのコードが再びサーバーに移されています。
「MVC のビュー」からアプリケーションアーキテクチャへ#
大規模な組織では、エンジニアの一定の割合がプラットフォームの一部として、アーキテクチャのベストプラクティスを組み込んでいます。
これらの開発者は、他の人々が限られた時間とエネルギーを実際の利益をもたらすことに集中できるようにします。
限られた時間と注意による影響の一つは、私たちが通常、最も簡単だと感じる方法を選択することです。したがって、これらの積極的な制約が私たちを正しい道に導き、成功の罠に簡単に陥ることができることを期待しています。
この成功の重要な部分は、エンドユーザーのデバイス上で読み込んで実行する必要のあるコードの量を減らすことです。必要なコンテンツのみをダウンロードして実行するという原則に従います。クライアントのみの例に制限されると、これを遵守することは難しいです。パッケージは最終的にデータ取得、処理、フォーマットライブラリ(例:moment.js)を含むことになりますが、これらはメインスレッドから外れて実行できます。
これは、Remix や Next のようなフレームワークで変化が起こっています。React の単方向データフローはサーバーに拡張され、MPA のシンプルなリクエスト - レスポンスメンタルモデルが SPA のインタラクティビティと組み合わさっています。
サーバーへの旅に戻る#
さて、時間の経過とともに、私たちがこのクライアントのみの例に適用した最適化について理解しましょう。これには、より良いパフォーマンスを得るためにサーバーを再導入する必要があります。この背景は、React フレームワークを理解するのに役立ちます。その中でサーバーは一等市民に進化しています。
以下は、クライアントレンダリングのフロントエンドにサービスを提供するためのシンプルな方法です - 多くの script タグを持つ空白の HTML ページ:
図は、クライアントレンダリングの基本原理を示しています。
この方法の利点は、迅速なTTFB(初バイト時間)、シンプルな操作モデル、疎結合のバックエンドです。React のプログラミングモデルと組み合わせることで、この組み合わせは多くの人間関係の問題を簡素化しました。
しかし、すぐに技術的な問題に直面します。すべての責任がユーザーのハードウェアに委ねられるからです。すべてのコンテンツがダウンロードされ、実行されるまで待たなければ、有用なコンテンツを表示することができません。
コードが蓄積されるにつれて、コードを格納する場所は一つだけです。慎重なパフォーマンス管理がなければ、アプリケーションが遅くなり、耐えられないほどになる可能性があります。
サーバーサイドレンダリングへの移行#
サーバーへの再訪の第一歩は、これらの遅い起動時間を解決しようとすることです。
初期ドキュメントリクエストに対して空白の HTML ページで応答するのではなく、サーバー上でデータを即座に取得し、コンポーネントツリーを HTML としてレンダリングして応答します。
クライアントレンダリングの SPA コンテキストにおいて、SSR は Javascript を読み込む際に最初に何かを表示するためのトリックのようなものであり、空白の白い画面ではありません。
図は、サーバーサイドレンダリングとクライアントハイドレーションの基本原理を示しています。
SSR は、特にコンテンツが豊富なページに対して知覚パフォーマンスを向上させることができます。しかし、操作コストをもたらし、高度にインタラクティブなページではユーザー体験を低下させる可能性があります —— なぜなら、TTI(インタラクティブ時間)がさらに遅れるからです。
これは「信じられない谷」と呼ばれ、ユーザーはページ上でコンテンツを見てインタラクションを試みますが、メインスレッドがロックされています。問題は依然として過剰な Javascript です。
スピードの要求 - さらなる最適化#
したがって、SSR はスピードを加速できますが、万能薬ではありません。
サーバーでレンダリングされた後、クライアントの React が引き継ぐ際に再実行する必要があるという固有の非効率性があります。
遅い TTFB は、ブラウザがドキュメントをリクエストした後、ヘッダー情報を受信するまで辛抱強く待たなければならないことを意味します。これにより、どのリソースをダウンロードする必要があるかがわかります。
ここでストリーミングが役立ちます。ストリーミングは、画面により多くの並行性をもたらします。
ChatGPT が全体の応答が完了する前に回転するアイコンを表示し続けると、多くの人がそれが壊れていると思い、タブを閉じるでしょう。したがって、私たちはできるだけ早く表示できるコンテンツを表示し、データとコンテンツが完成したときにそれをブラウザにストリーミングします。
動的ページのストリーミングは、サーバー上でデータを早期に取得し、ブラウザがリソースのダウンロードを開始できるようにする方法です。これらはすべて並行して行われます。これは、上記の図よりもはるかに速く、すべてのコンテンツが取得され、レンダリングされるのを待ってから、データを含む HTML をクライアントに送信するのです。
ストリーミングに関する詳細情報
このストリーミング技術は、バックエンドサーバースタックまたはエッジランタイムがストリーミングデータをサポートできるかどうかに依存します。
HTTP/2の場合、HTTPストリーム(複数のリクエストと応答を同時に送信できる機能)を使用し、HTTP/1の場合は、データを小さな独立したチャンクに分割して送信するTransfer-Encoding: chunkedメカニズムを使用します。
現代のブラウザにはFetch APIが組み込まれており、取得した応答を読み取り可能なストリームとして消費できます。
応答のbodyプロパティは読み取り可能なストリームであり、クライアントはサーバーが提供する際にデータを逐次受信でき、すべてのチャンクが一度にダウンロードされるのを待つ必要はありません。
この方法は、サーバー上でストリーミング応答を送信する能力を設定し、クライアント上で読み取る必要があります。これには、クライアントとサーバー間の密接な協力が必要です。
ストリーミングには、キャッシュの考慮事項、HTTPステータスコードとエラーの処理、実際のエンドユーザー体験など、いくつかの注意すべき微妙な違いがあります。ここでは、迅速なTTFBとレイアウトの変換の間にトレードオフがあります。
これまで、私たちはクライアントレンダリングツリーの起動時間を最適化し、サーバー上でデータを早期に取得し、HTML を早期に更新してデータとリソースを並行してダウンロードできるようにしました。
今度は、データの取得と変更に焦点を当てましょう。
データ取得の制約#
階層的なコンポーネントツリーの制約の一つは、「すべてはコンポーネントである」ということです。これは、ノードが通常、取得操作を開始したり、読み込み状態を管理したり、イベントに応答したり、レンダリングしたりするなど、複数の責任を持つことを意味します。
これは通常、何を取得するかを知るためにツリーを遍歴する必要があることを意味します。
初期の頃、SSR を通じて初期 HTML を生成することは、通常、サーバー上で手動でツリーを遍歴することを意味しました。これは、React の内部に深く入り込み、すべてのデータ依存関係を収集し、ツリーを遍歴しながら順番に取得することを含みます。
クライアントでは、この「先にレンダリングしてから取得する」順序が、読み込みインジケーターとレイアウトの変動を同時に引き起こします。なぜなら、ツリーを遍歴することで連続的なネットワークの滝効果が生じるからです。
したがって、私たちは、ツリーを上から下へ遍歴することなく、データとコードを並行して取得できる方法を必要としています。
Relay を理解する#
Relay の背後にある原則と、それが Facebook 規模の課題にどのように対処しているかを理解することは非常に有用です。これらの概念は、後で見るパターンを理解するのに役立ちます。
-
コンポーネントは並置されたデータ依存関係を持つ
Relay では、コンポーネントは GraphQL フラグメントの形式で宣言的にデータ依存関係を定義します。
React Query のような並置特性を持つライブラリとの主な違いは、コンポーネントが取得操作を開始しないことです。
-
ツリーの遍歴は構築時に発生する
Relay コンパイラはツリーを遍歴し、各コンポーネントのデータ要求を収集し、最適化された GraphQL クエリを生成します。
通常、このクエリは実行時のルーティング境界(または特定のエントリポイント)で実行され、コンポーネントコードとデータが早期に並行してロードされることを可能にします。
並置は、コードを削除できるという最も価値のあるアーキテクチャ原則の一つをサポートします。コンポーネントを削除することで、そのデータ要求も削除され、クエリはもはやそれらを含まなくなります。
Relay は、大規模なツリー構造のデータを取得する際に伴う多くのトレードオフを軽減します。
しかし、これは複雑であり、GraphQL、クライアントランタイム環境、および高性能を維持しながら DX 属性を調整するための高度なコンパイラを必要とします。
後で、React サーバーコンポーネントがどのようにより広範なエコシステムに類似のパターンを従うかを見ていきます。
次の最良の選択肢#
データとコードを取得する際に、ツリーを遍歴せずに、すべての方法を採用せずにどうすればよいでしょうか?
これが、Remix や Next のようなフレームワークでサーバー上のネストされたルーティングが機能する理由です。
コンポーネントの初期データ依存関係は通常、URL にマッピングできます。ここで、URL のネストされたセグメントはコンポーネントのサブツリーにマッピングされます。このマッピングにより、フレームワークは特定の URL に必要なデータとコンポーネントコードを事前に特定できます。
たとえば、Remix では、サブツリーは親ルートから独立して自分自身のデータ要求を持つことができます。コンパイラはネストされたルーティングを並行してロードすることを保証します。
このカプセル化は、独立したサブルートに個別のエラーバウンダリを提供することによって優雅な降格を実現します。また、フレームワークは URL を見てデータとコードを事前にプリロードすることができ、より迅速な SPA 変換を実現します。
さらなる並行化#
Suspense、concurrent mode、ストリーミングが、私たちが探求しているデータ取得パターンをどのように強化するかを深く掘り下げてみましょう。
Suspense は、データが利用できないときにサブツリーがローディングインターフェースを表示し、データが準備できたときにレンダリングを再開できるようにします。
これは、もともと同期的なツリーの中で非同期性を宣言的に表現できる原語です。これにより、リソースを取得しながらレンダリングを並行して実現できます。
ストリーミングで見たように、すべてのコンテンツがレンダリングを完了するのを待たずに、早期にデータを送信し始めることができます。
Remix では、このパターンはルートレベルのデータローダーで defer 関数を使用することで表現されます:
// Remix APIs encourage fetching data at route boundaries
// where nested loaders load in parallel
export function loader ({ params }) {
// not critical, start fetching, but don't block rendering
const productReviewsPromise = fetchReview(params.id)
// critical to display page with this data - so we await
const product = await fetchProduct(params.id)
return defer({ product, productReviewsPromise })
}
export default function ProductPage() {
const { product, productReviewsPromise } = useLoaderData()
return (
<>
<ProductView product={product}>
<Suspense fallback={<LoadingSkeleton />}>
<Async resolve={productReviewsPromise}>
{reviews => <ReviewsView reviews={reviews} />}
</Async>
</Suspense>
</>
)
}
Next では、RSC(React サーバーコンポーネント)が、サーバー上で非同期コンポーネントを使用して重要なデータを待つ類似のデータ取得パターンを提供します。
// Example of similar pattern in a server component
export default async function Product({ id }) {
// non critical - start fetching but don't block
const productReviewsPromise = fetchReview(id)
// critical - block rendering with await
const product = await fetchProduct(id)
return (
<>
<ProductView product={product}>
<Suspense fallback={<LoadingSkeleton />}>
{/* Unwrap promise inside with use() hook */}
<ReviewsView data={productReviewsPromise} />
</Suspense>
</>
)
}
ここでの原則は、サーバー上でデータを早期に取得することです。理想的には、ローダーと RSC をデータソースの近くに配置することで実現します。
不必要な待機を避けるために、あまり重要でないデータをストリーミングし、ページが段階的に読み込まれるようにします。これは Suspense の中で非常に簡単になります。
注意すべき点は、RSC 自体にはルーティング境界でデータを取得するための組み込み API がないことです。慎重に構築しないと、連続的なネットワークの滝のようなリクエストが発生する可能性があります。
これは、フレームワークが組み込みのベストプラクティスと、より大きな柔軟性と誤操作のためのより多くの表面を提供することの間でトレードオフを行う必要がある境界線です。
RSC がデータの近くに展開されると、クライアントの滝のようなリクエストと比較して、連続的な滝のようなリクエストの影響が大幅に軽減されることも言及する価値があります。
これらのパターンを強調することは、RSC が特定のコンポーネントに URL をマッピングできるルーターとのより高度なフレームワーク統合を必要とすることを示しています。
RSC について詳しく掘り下げる前に、この絵の別の側面について少し時間をかけて考えましょう。
データの変更#
クライアントのみのモデルでリモートデータを管理する一般的なパターンは、何らかの正規化ストレージ(例:Redux ストレージ)に保存することです。
このモデルでは、変更は通常、楽観的にメモリ内のクライアントキャッシュを更新し、その後、ネットワークリクエストを送信してサーバー上のリモート状態を更新します。
歴史的に、これらの内容を手動で管理することは、多くのボイラープレートコードを伴い、私たちが「React 状態管理の新しい波」で議論したすべてのエッジケースでエラーを引き起こしやすいものでした。
Hooks の登場により、Redux RTK や React Query のような、これらのエッジケースを処理することに特化したツールが登場しました。
これにより、これらの問題を処理するためのコードをネットワーク経由で送信する必要があり、値は React コンテキストを通じて伝播します。それに加えて、ツリーを遍歴する際に非効率的な順序 I/O 操作を作成することも容易になります。
では、React の単方向データフローがサーバーに拡張されると、この既存のモデルはどのように変わるのでしょうか?
この「フロントエンドの背後」にあるスタイルのコードの多くは、実際にはバックエンドに移行しました。
以下は、Remix におけるデータフローからの画像で、フレームワークが MPA(マルチページアプリケーション)アーキテクチャのリクエスト - レスポンスモデルに向かって進化している傾向を示しています。
この変化は、すべての事柄を純粋にクライアントが処理するモデルから、サーバーがより重要な役割を果たすモデルへの移行です。
この変化について詳しく知りたい方は、「ウェブの次の転換」を参照してください。
このモデルは RSC(React サーバーコンポーネント)にも拡張され、後で実験的な「サーバー操作関数」について紹介します。ここで、React の単方向データフローはサーバーにまで延び、簡素化されたリクエスト - レスポンスモデルと段階的に強化されたフォームを採用します。
クライアントからコードを削除することは、このアプローチの利点の一つです。しかし、主な利点は、データ管理のメンタルモデルを簡素化することであり、これが既存の多くのクライアントコードを簡素化します。
React サーバーコンポーネントを理解する#
これまで、私たちはサーバーを純粋なクライアントアプローチを最適化する手段として利用してきました。今、私たちは React のメンタルモデルがユーザーのマシン上で実行されるクライアントレンダリングツリーに深く根ざしていることを理解しています。
RSC(React サーバーコンポーネント)は、サーバーを一等市民として導入し、事後的な最適化ではありません。React は成長し、バックエンドがコンポーネントツリーに埋め込まれ、強力な外層を形成します。
このアーキテクチャの変化は、React アプリケーションが何であるか、どのようにデプロイされるかに関する多くの既存のメンタルモデルの変化をもたらしました。
最も明白な 2 つの影響は、これまで議論してきたデータロードの最適化パターンのサポートと、自動コード分割です。
「大規模なフロントエンドの構築と提供」の第 2 部では、依存関係管理、国際化や最適化された A/B テストなど、大規模な重要な問題について議論しました。
純粋なクライアント環境に制限されると、これらの問題は大規模に解決するのが難しい場合があります。RSC や React 18 の多くの機能は、これらの問題を解決するための基本的なツールセットをフレームワークに提供します。
混乱を招くメンタルモデルの変化は、クライアントコンポーネントがサーバーコンポーネントをレンダリングできるようになったことです。
これは、RSC を持つコンポーネントツリーを視覚化するのに役立ちます。これらはツリーに沿って接続されています。クライアントコンポーネントは「ホール」を通じて接続され、クライアントのインタラクションを提供します。
サーバーをコンポーネントツリーの下に拡張することは非常に強力です。なぜなら、不要なコードを下に送信するのを避けることができるからです。また、ユーザーのハードウェアとは異なり、サーバーリソースに対してはより多くの制御が可能です。
ツリーの根はサーバーに根ざし、幹はネットワークを通過し、葉はユーザーのハードウェア上で実行されるクライアントコンポーネントにプッシュされます。
この拡張モデルは、コンポーネントツリー内のシリアライズ境界を理解することを要求します。これらの境界は 'use client' 指令でマークされています。
これは、RSC がクライアントコンポーネントのサブコンポーネントやスロットにできるだけ深くレンダリングされるように、組み合わせの重要性を再強調します。
サーバー操作関数#
私たちがフロントエンドの一部の領域をサーバーに移行するにつれて、多くの革新的なアイデアが探求されています。これらは、クライアントとサーバーの間のシームレスな統合の未来を垣間見るものです。
もし、クライアントライブラリ、GraphQL、または実行時の低効率の滝を心配することなく、コンポーネントと共に位置を取得できるとしたらどうでしょうか?
サーバー機能の例は、React スタイルのメタフレームワーク Qwik city で見ることができます。類似のアイデアは、React(Next)や Remix でも探求され、議論されています。
Wakuwork リポジトリは、React サーバー「操作関数」の実装に向けたデータ変異の概念実証を提供しています。
すべての実験的な方法と同様に、考慮すべきトレードオフがあります。クライアント - サーバー通信に関しては、安全性、エラーハンドリング、楽観的更新、再試行、競合状態に関する懸念があります。私たちが学んだように、フレームワークが管理しない限り、これらの問題は通常解決できません。
この探求は、最良のユーザー体験と最良の開発者体験を実現するためには、しばしば複雑性を高める高度なコンパイラ最適化が必要であることを強調しています。
結論#
ソフトウェアは人々が何かを成し遂げるのを助けるためのツールに過ぎない - 多くのプログラマーはこれを理解していない。提供される価値に目を向け、ツールの詳細に過度に焦点を当てないこと - ジョン・カーマック
React エコシステムが純粋なクライアントの例を超えて発展する中で、私たちの下と上の抽象を理解することが重要です。
私たちが操作する基本的な制限を明確に理解することで、より賢明なトレードオフの決定を下すことができます。
揺れ動くたびに、私たちは新しい知識と経験を得て、次のイテレーションに統合します。以前の方法の利点は依然として有効です。いつものように、これはトレードオフです。
素晴らしいことは、フレームワークがますます多くのレバレッジツールを提供し、開発者が特定の状況に対してより微細なトレードオフを行えるようにすることです。ユーザー体験の最適化と開発者体験の最適化が交差し、シンプルなモデルの MPA とリッチモデルの SPA がクライアントとサーバーの混合の中で交わります。