🐋

なぜルーティングにData Fetchの責務(Loader API)があるのかを考える

2024/06/11に公開

近年のReactのルーティングライブラリには当たり前のようにデータ取得を行うAPIが提供されています。その先駆けになったのがReact Routerでしょうか。React RouterではRemixから逆輸入される形でLoader APIが提供され、ルートごとにデータ取得を実行することができるようになりました。

https://reactrouter.com/en/main/route/loader

それと同様にTanStack RouterにもLoader APIが存在しデータ取得を行うことができます。では、なぜルーティングに責務を持つライブラリがデータ取得のAPIを提供しているのでしょうか。

https://tanstack.com/router/latest/docs/framework/react/guide/data-loading

⭐️本記事はこのLoader APIがSPAでデータ取得をする際に如何に重要な存在であるかを、2つの視点から整理することを目的とします。

はじめに

ここでは以下2つの視点からLoader APIの重要性を整理します。

  1. 無駄なRequest Waterfallを防ぐ役割
  2. ページ遷移前にPreloadをする役割

前提

本記事ではルーティングにTanStack Routerを、サーバデータの状態管理にTanStack Queryを採用して解説します。ですが本質的な概念は他のライブラリを使用しても共通すると考えます。

  • TanStack Router
    • v1.35.3
  • TanStack Query
    • v5.40.2

⛲️ 無駄なRequest Waterfallを防ぐ役割

Loader APIを活用することによって無駄なRequestのWaterfallを防ぐことができます。

と、その前にRequest Waterfallとは何かを解説したいと思います。

Request Waterfall問題とは?

ここでは無駄なRequest Waterfallが発生するパターンを2つ紹介します。

https://tanstack.com/query/v5/docs/framework/react/guides/request-waterfalls

1. クエリに依存が発生するパターン

下の例ではまずemailからユーザ情報を取得し、取得したユーザIDをもとにプロジェクトを取得しています。TanStack Queryではこのようにenabledオプションを使用することで容易にクエリの依存関係を実装することができます。

// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})

あまりにも簡単に依存したクエリを実装できるため見落としてしまいますが、この実装ではプロジェクトを取得するまでに2回のネットワークリクエストが発生しています。

1. |-> getUserByEmail
2.   |-> getProjectsByUser

これが一つ目のRequest Waterfallが発生するパターンです。そして本来はプロジェクトのデータを取得するまでのリクエストは1回に抑えたいところです。

方法はいくつか考えられますが、上記の例ではemail情報からプロジェクトを取得できるようにAPIの設計を見直すのも一つの方法だと考えられます。

2. コンポーネントがネストしたパターン

先ほどはクエリに依存関係が生まれることで問題が発生する例を紹介しました。次にコンポーネントがネストすることによって無駄なRequest Waterfallが生まれるパターンを紹介します。

この例ではArticleCommentsが親子関係にあり、それぞれのコンポーネント内でデータ取得をしています。

function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )

}

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}

この実装にはどのような問題があるのでしょうか?

実際にコンポーネントがレンダリングされる流れを確認します。

  1. Articleがレンダリングされる
  2. Article内のuseQueryが呼ばれ、getArticleById(データ取得)が実行される
  3. articleDataが取得され、isPendingがfalseになる
  4. Commentsがレンダリングされる
  5. Comments内のuseQueryが呼ばれ、getArticleCommentsById(データ取得)が実行される

この流れを見ると違和感を感じるかと思います。なぜならArticleとCommentsはお互いクエリに依存関係がないにも関わらず、Articleのデータ取得を待ってからCommentsのデータ取得が開始されているからです。

先ほどと同様にデータ取得に依存関係が生まれています。

1. |-> getArticleById
2.   |-> getArticleCommentsById

しかし、依存関係のないクエリは並行してデータ取得を行なって欲しいはずです。

1. |-> getArticleById
1. |-> getArticleCommentsById

そのための対応策として親のコンポーネントでデータ取得をまとめる方法があります。下の例では先ほどと異なり、Article(親)でComments(子)のデータも取得しpropsで伝播する形に変わっていると思います。

function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  if (articlePending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        'Loading comments...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}

このように親でデータフェッチをまとめることによってRequest Waterfallを防ぐことができます。

しかし、これでは次に紹介するSuspenseを活かした設計をすることが困難になります。

Suspenseを活用することで生まれるRequest Waterfall

先ほど紹介したようにデータフェッチをページのトップにまとめて、取得したデータを子コンポーネントへ伝播することによりRequest Waterfallを防ぐことができるようになりました。近年のReactの設計においてこのようにページのトップでデータ取得をする形式が多くみられたのは、テスタビリティなどのメリットに加えてパフォーマンスへの考慮もあったように思います。

Suspenseを用いたデータ取得

ところでReact17からSuspense APIが登場しデータ取得とローディングの扱いが非常にシンプルになりました。Suspenseを使用すると囲った単位がSuspenseの境界となり、その中でコンポーネントがサスペンドされると登録したfallbackが表示されます。

下の例ではBiography内でデータ取得が完了するまでコンポーネントがサスペンドされ、fallbackに登録したBigSpinnerが表示されます。そして、次のAlbums内のデータ取得が開始されるとfallbackに登録したAlbumsGlimmerが表示され、完了するとコンポーネントが表示されます。

<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>

https://ja.react.dev/reference/react/Suspense#revealing-nested-content-as-it-loads

仮にこの実装をSuspenseを使わずに考えてみます。すると以下のようになるでしょう。明らかにローディングへの関心が増えていることがわかりますし、さらに取得するデータが増えた際にはより複雑な実装になります。

const { data: biographyData, isPending: isBiographyPending } = useQuery({ 省略 })
const { data: albumData, isPending: isAlbumsPending } = useQuery({ 省略 })

if (isBiographyPending) {
    return <BigSpinner />;
}

return (
    <>
      <Biography biographyData={biographyData} />
      {isAlbumsPending ? (
        <AlbumsGlimmer />
      ) : (
        <Panel>
          <Albums albumData={albumData} />
        </Panel>
      )}
    </>
);

Suspenseを使用するとRequest Waterfallが発生してしまう

しかし、Suspenseを用いて子コンポーネントでデータ取得をするには注意が必要です。

先ほどの例をもとにデータ取得の流れを整理します。

  1. Biographyがレンダリングされる
  2. Biography内のuseSuspenseQueryが呼ばれ、データ取得が実行される。データが用意されるまではfallback(BigSpinner)が表示される
  3. Biographyのレンダリングが完了し、Albumsがレンダリングされる
  4. Albums内のuseSuspenseQueryが呼ばれ、データ取得が実行される。データが用意されるまではfallback(AlbumsGlimmer)が表示される
<Suspense fallback={<BigSpinner />}>
  <Biography />
  // コンポーネント内でuseSuspenseQueryを使用
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      // コンポーネント内でuseSuspenseQueryを使用
      <Albums />
    </Panel>
  </Suspense>
</Suspense>

つまりコンポーネントがネストしたパターンで紹介した際と同様の理由で、無駄なRequest Waterfallが発生してしまいます。

1. |-> getBiography
2.   |-> getAlbums

SuspenseとLoader APIを組み合わせる

前置きが長くなってしまいましたが、ここでRouterが提供するLoader APIの出番です。

実際に例で見ていきましょう。ここではTanStack Routerのドキュメントを参照してTanStack Queryと組み合わせる方法を考えます。

export const Route = createFileRoute('/biography/albums')({
  loader: () => {
    // biographyのデータを取得し、TanStack Queryのキャッシュに追加
    queryClient.ensureQueryData(biographyQueryOptions)
    // albumsのデータを取得し、TanStack Queryのキャッシュに追加
    queryClient.ensureQueryData(albumsQueryOptions)
  }
}

https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading

ensureQueryDataというAPIはTanStack Queryのキャッシュにデータがなければデータ取得をしキャッシュする、キャッシュが既に存在すればデータ取得をスキップするという役割を持ちます。

このAPIをLoaderで使用することにより、コンポーネントがレンダリングされる前にデータ取得を開始でき、データが取得され次第TanStack Queryのキャッシュに登録することができます。

データ取得が並行して行われる

上記の対応によりどのようなデータ取得のフローになるかを確認します。

<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>
  1. 対象のルートがマッチし、Loader APIが実行される。
  2. biographyとalbumsのデータ取得が並行して走る。
  3. ページがレンダリングされる。
  4. Biographyのデータが用意されるまでサスペンドし、完了次第レンダリング
  5. Albumsのデータが用意されるまでサスペンドし、完了次第レンダリング

https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#the-route-loading-lifecycle

これによってコンポーネントのレンダリングを待たずしてデータ取得が並行して行われるようになり、Request Waterfallが解消されました。

1. |-> getBiography
1. |-> getAlbums

それに加えてSuspenseを活かすことができ非常にシンプルで効率的な設計になったと思います。

整理

Reactのデータ取得の戦略を考える際に、コンポーネント内部でデータを取得してしまうと「親のレンダリングを待ってから子のデータ取得が開始される」というパフォーマンスの懸念がありました。

そこで各ルートごとにページのトップでデータ取得をまとめることにより、並行してデータを取得しRequest Waterfall問題を解消していました。

そんな中、React 17からSuspense APIが登場し、TanStack QueryのようなライブラリがSuspenseに対応したことによって、よりシンプルにデータ取得とローディングの実装ができるようになりました。

しかし、Suspenseを活用してデータ取得を行うと当初懸念していた「親のレンダリングを待ってから子のデータ取得が開始される」というRequest Waterfall問題が再発してしまいます。

そこでルートごとに定義されたLoader APIを活用します。すると、「コンポーネントのレンダリングが開始する前にデータ取得を並行して実行できる」ようになりました。

これがルーティングがData Fetchの責務(Loader API)を担う理由の一つだと考えます。そのページでどんなデータが必要かをルーティング側が知る必要があるからです。

🛫 ページ遷移前にPreloadをする役割

次に紹介するページ遷移前のPreloadはおそらくユーザが次に遷移するであろうページのルートとデータを事前に用意しておくというものです。

https://tanstack.com/router/latest/docs/framework/react/guide/preloading

TanStack Routerではリンクをhoverした際に遷移先のルートとデータを事前に用意する機能を持っています。

これによって、本来ユーザがページに遷移してから開始されるデータ取得を事前に開始することができます。これはTanStack Queryのようなキャッシュ機構をもったライブラリと組み合わせるからこそ実現できるメリットでもあります。

PreloadのUXを体験する

PreloadによるUXの向上は実際に体感するのが早いかと思います。そのため、Preloadを実装したページとそうでないページを2つ用意しました。

前提

  • 今回用意したページはTanStack RouterのドキュメントにあるExampleをカスタマイズしています。
  • jsonplaceholderからポストの一覧とポストの詳細を取得し表示します。
  • ポストの詳細を取得するために3sの遅延を持たせています。

Preloadを実装していないページ

https://stackblitz.com/edit/vitejs-vite-dr7zsd?embed=1&file=README.md&hideNavigation=1&view=preview

Preloadを実装していない場合、各ポストの詳細に遷移するたびに3秒のデータ取得のためのPendingが発生します。これはページに遷移してルートが解決されてから、データ取得を開始しているためです。

Preloadを実装したページ

https://stackblitz.com/edit/vitejs-vite-myqg5t?embed=1&file=README.md&hideNavigation=1&view=preview

こちらはPreloadを実装した場合のページです。すると、リンクをHoverしたタイミングでデータ取得が開始され場合によってはページ遷移のタイミングでPendingが発生しない時もあることがわかります。

もちろんリンクのホバーと同時のタイミングでページ遷移をすればPendingが発生します。しかしこのような一覧ページから詳細へ遷移するケースでは、どのページに遷移するか迷ってリンクを複数ホバーするストーリーは十分想定されます。このようなケースにおいてはデータのPreloadはUXを向上させる意味で非常に有効だと考えます。

Preloadする対象はルートが知っている

そして本題へ戻りなぜルーティングにData Fetchの責務(Loader API)があるのかを考えます。

先ほどPreloadによってUXが大幅に向上することが体感いただけたかと思います。しかし、なぜユーザが次に遷移する可能性のあるページのデータをあらかじめ用意することができるのでしょうか?

そうでない場合、リンクをホバーして次に遷移する可能性のあるページがわかったところで必要なデータを用意することは困難です。ページで必要なデータをあらかじめルートが知っていることで、ページ遷移の前にPreloadしてデータを用意することが可能になります。

まとめ

本記事ではRequest Waterfall問題の解消PreloadによるUXの向上という二つの視点からなぜルーティングにData Fetchの責務(Loader API)があるのかを考えてきました。

Request Waterfall問題はSuspenseの境界が深くなるほどパフォーマンスの課題が生まれます。これは実際に意識をして開発しないと見落としてしまいますが重要な課題です。

Preloadによるデータの事前準備は、一覧画面から詳細へ遷移するケースなどにおいて非常にUXを向上させるポイントになります。

そしてこれらはルーティング側がそのページで必要なデータ取得を知っているからこそ実現できるパフォーマンス改善でしょう。結論、これらの観点からルーティングにData Fetchの責務(Loader API)があると考えます。

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)

【面談フォームはこちら】

https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion