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 がクライアントとサーバーの混合の中で交差します。