📖

Reactはどうやってデータfetchしてきたか 〜SPAからNext.jsとRemixまで〜

2024/10/08に公開

これは何

我々の住むReact世界も、紀元10年になりました。
この記事は、この世界が「データの取得」という課題にどう向き合ってきたか・今どうしようとしているか、細かい実装ではなく、大きな設計の歴史を語ってみる試みです。
最後に現在の到達点を代表してNext.js(App Router)とRemixを比べます。

SPA

はじめに神はブラウザへReactを与えられました。
ブラウザはReactの力でデータを取得し、HTMLを作れるようになったので、サーバーは空のHTMLと<script/>タグを返すだけになりました。

SSR

仕事がないことを寂しがったサーバーは、自分もReactを使って、初期表示用のHTMLだけでも作ろうとしました。

しかしReactはブラウザに与えられたものですから、サーバーには真似できないこともあります。その1つがデータ取得でした。

Reactコンポーネントは特殊な関数です。コンポーネント自体が状態の更新で何十度でも実行されますし、ブラウザから叩くAPIを叩くしかありません。

その結果、サーバーにはどうにも解釈できないコードがあちこちにあります。

const Component = () => {
  const [data, setData] = useState();//これが状態を表す
  useEffect(() => { //1回しか実行しないよの印
    fetch('https://api.com') 
      .then((res)=>res.json())
      .then((data)=>{setData(data)})
  }, []);
  return <>{data}</>;
};

しかし、データ取得を全て諦めてしまえば大したHTMLは作れません。
困ったサーバーは、全く独自の方法でデータを取ることにしました。
Remixのloader,Next.jsのgetServerSidePropsです。
取ったデータは、Reactの最上位のコンポーネントにpropsとして渡しました。

//ここはフレームワークの世界
export async function getServerSideProps() {
  const data = await fetch('https://api.com');
  return {
    props: { data },
  };
}
//ここから下がReactの世界
export default function Page({ data }) {
  return <>{data}</>;
}

こうしてSSRフレームワークができました。
サーバーは独自にデータを取得し、ブラウザ用のReactコンポーネントを苦労して動かし、HTMLを送信します。
ブラウザは改めて全てのコンポーネントを実行して、サーバーには作れなかった部分を反映させます。(この反映はハイドレーションと呼ばれました。)

サーバーたちがすっかり完成のHTMLまで作成し、ブラウザは僅かなイベント登録をするだけ、というサイトもそこかしこに生まれてきました。

Reactはこれらサーバーの努力に驚き、また祝福して言われました。
「ちょっとこっち来て」

サバコン世界

Reactは、サーバーたちが輝くための新しい世界を作ったのでした[1]。サバコン世界と呼びましょう。

そもそも、太古よりサーバーたちは関数でHTMLを作ってきました。

import createHeading from './createHeading';
const HTMLRenderer = () => {
  return (
    `<div>
      ${createHeading('見出し')}
      <p>本文だよ</p>
    </div>`
  );
};

ここにはサーバーにできることなら何でも書けました。
非同期でも良いですし、サーバーからしかアクセスできないデータを取るのも自由です。
その代わりに、当然静的なHTMLしか出力できません。

const HTMLRenderer = async () => {
  const res = await fetch('https://serverOnly.com');
  const data = await res.json();
  return (
    `<p>{data}</p>`
  );
};

Reactはこれらの関数に

  • JSXの力
  • 他のReactコンポーネントを埋め込む力

を与えられ、「サーバーコンポーネント」と名づけられました。[2]

import NormalComponent from './NormalComponent';
const ServerComponent = () => {
  return (
    <div>
      <h1>このボタン押せるよ</h1>
      <NormalComponent />
    </div>
  );
};
'use client';//通常コンポーネントの印
const NormalComponent = () => {
  return (
    <button
      onClick={() => {
        console.log('こいつはブラウザで動く');
      }}
    >
      ボタン
    </button>
  );
};

そして、旧世界から連れてきたReactコンポーネントを振り返り、これからは「クライアントコンポーネント」を名乗るよう言われました。

こうして、新世界にはReactコンポーネントは2種類になりました。

サーバーコンポーネント(SC)

  • サーバーでレンダリングされる
  • 初期表示から一切変化しない
  • サーバーにできることは何でも書ける

クライアントコンポーネント(CC)[3]

  • まずサーバー、次にブラウザでレンダリングされる
  • ハイドレーションされ=<script />がついていて[4]、状態を持ち操作に反応できる
  • 従来のReact記法に従い、ブラウザでもできることだけが書ける

Reactはサーバーたちに言われました。
「2種類のコンポーネントを自由に組み合わせてサイトを作れ。全ては最初SCであるから、ハイドレーションが必要なコンポーネントにはuse clientという印を書き加えてCCに変えよ」

図:frontendmastery

この世界は、

  • サーバーが最初のHTMLを作る
  • ユーザーの操作に反応するところだけ<script/>が処理する

という原始からの自然な分業に回帰するものでした。ただ原始と違うのは、Reactの力に満ちていることです。

Next.js

Next.jsは歓喜し、サバコン世界を楽園と考え、すぐに移民しました。
データ取得はサーバーコンポーネントに任せ、独自の方法は捨て去りました。

getInitialPropsは言ってしまえば苦肉の策でした。 
Reactの外にあるので、最上位のコンポーネントにしかデータを渡せません。
これでは、

  • データが必要なコンポーネントは、特定のルートでしか動かない
  • ルートとUIの間の全てがデータのバケツリレーに参加する

ことになり、コンポーネントたちの間に不自由な依存関係が生まれてしまいます。
Screenshot 2024-10-07 at 12.29.50.png

コンポーネントがサーバーサイドでデータを取得できるなら、UIとそれに必要なデータ取得を両方行って貰えばよいのです。

Screenshot 2024-10-07 at 12.30.05.png

こうして、コンポーネント(Component)は「組み合わせる(Compose)の自由」を取り戻すことができました。

Remix

Remixは急いで移住することはしませんでした。
Remixの独自のデータ取得関数loaderには、新世界でコンポーネントにfetchを任せるよりも高速に動作できる秘密があったからです。それは並列処理ができることです。

公式サイトTOPの説明が素晴らしいので、ここでは補足することしかできません。
https://remix.run/

この例では、以下のようなコンポーネントツリーがあり、全てがfetchデータを必要としています。

画面全体 <Root/>
↓
セールス <Sales/>
↓
請求書一覧 <Invoices/>
↓
請求書 <Invoice id={id}/>

Reactのレンダリングは、サバコン世界で各コンポーネントにfetchを任せるとこのような処理順序になります。

  1. Rootのfetch・レンダリング
  2. Salesのfetch・レンダリング
  3. Invoicesのfetch・レンダリング
  4. Invoiceのfetch・レンダリング

この様を「Request Waterfall」と呼びます。
「これでは下層コンポーネントの表示を待つユーザーが不憫であります」とRemixは言いました。

これを速めるには、fetchを並列処理するしかありません。
しかし、Reactは親コンポーネントを実行し終わるまで子コンポーネントが何になるかを知りませんし、ましてや最終的に実行されるfetchの一覧など持ってはいません。

そこでRemixはReactを飛び越え、開発者と密約を結びました。

そもそもNext.jsやRemixと開発者は、URLと最上位のコンポーネントを対応させる約束をすでにしています。File Based Routingというものです。

/dashboard
↓
<Dashboard />

これを拡張して、URLの各階層をコンポーネントツリーの階層と対応させるのはどうでしょう。

URL
/最上位コンポーネント/次のコンポーネント/孫コンポーネント
/sales/invoices/10200
↓
<Sales>
  <Invoices>
    <Invoice id="10200"/>
  </Invoices>
</Sales>

もちろん、ある程度の階層までで構いませんが、このルールに従う上位数層のコンポーネントには特別な力が宿るので、Route Moduleと名付けます。

Route Moduleはみなデータ取得関数loaderで自身に必要なデータを定義できることにします。

sales.tsx
export const loader = async () => {
  const data = await fetch('https://api/sales')
  return json(data);
};

export default function SalesUI() {
  const data = useLoaderData<typeof loader>();
  return <div>{data}<Invoices/></div>
}

この密約とloaderの記述により、/sales/invoices/10200へのアクセスがあった瞬間、Remixは必要なfetchの全てを把握できるようになりました。

そうなれば、処理順序はこうできます。

  1. loader処理(該当する全てのRoute Moduleのloader≒fetchを並列処理)
  2. Rootのレンダリング
  3. Salesのレンダリング
  4. Invoicesの(ry
  5. Invoiceの(ry

この仕組みを、Nested Routeと呼びます。URLとコンポーネントツリー(結果的にUIの構造)を一致させる縛りを受け入れる範囲でfetchを並列処理するという、Remixと開発者の契約です。この仕組みを持っているために、Remixはサバコン世界に移る理由がありませんでした。

歴史のまとめはここまでです。色々ありましたね。


これから

最後に、Next.jsとRemixの現在地について、データ取得という1点から筆者の感想をメモしておきます。

Next.jsの進むサバコン世界は王道に見えます。
演算をなるべくサーバーに寄せ不可能な部分だけをブラウザに残す方針は、キャッシュ共有・処理能力の両方の理由で圧倒的に効率的であり、サバコン世界はそれをコンポーネントの独立性を守ったまま行える世界です。
これが主流にならないなら、それはWEB開発者に好かれないからではなく、サバコンの処理が複雑すぎてライブラリが対応できないなどの、より低レイヤーの技術問題のためでしょう。

Remixの提案する並行fetchは強力ですが、Nested Routeの重い縛りを相殺するほどの速度差を生むサイトは少数派です。
そもそもマーケティングサイトなど、皆に同じ内容を見せるサイトでは、ページ全体をCDNにキャッシュさせますから、オリジンのfetch戦術と表示速度は関係がありません。
また、多少ユーザー個別のfetchあったとしてもサーバーでのfetchの多くは100ms以下ですから、数百ミリ秒の短縮効果を生むに過ぎません。本当に生きるのは、銀行口座・SNS・システム管理画面といった多数のユーザー個別データを表示・更新するサイトかと思います。

Remixにはデータ取得以外に良い点がたくさんあるので、そのうちサバコン世界に引っ越してくるかもしれませんね!

脚注
  1. 現時点ではReactの特定のバージョンだけがこれらしい ↩︎

  2. この時点で実際はHTML文字列ではなく、DOMや子孫コンポーネントを示すJSON=サーバーコンポーネントペイロードを返すようになったが、意識することはない ↩︎

  3. 両方で処理されるわけなので、ダブルコンポーネントとかハイドレーションコンポーネントとでも呼ぶべきですが、なぜかこの名前です ↩︎

  4. 実際は各コンポーネントの処理をまとめた大きなscript=クライアントバンドルがあるようですが、詳しくは知りません ↩︎

Discussion