🐢

React Router の defer で重要でないデータの取得を遅延させる

2022/11/13に公開約7,700字

付随的なデータの取得を待たずに、重要なデータの取得が完了したタイミングでページを表示させたい場合があります。例えばブログの記事のページへ遷移する場合、ユーザーにとって記事のコンテンツは重要なデータですが、それに付随するコメントやいいねの数はそれほど重要ではありません。このような場合にはコメントやいいねの数の取得を待たずに、記事のコンテンツが取得できたタイミングでページ遷移できるとユーザー体験が向上します。

この記事では React Router の loader を使用して重要なデータの完了したタイミングでページを表示する方法を試してみます。

通常通り loader を使用する場合

まずは特別なことを考えずに通常通り loader を使用してデータの取得してみましょう。以下のように <ArticlePage> コンポーネントを作成します。

src/pages/Article.tsx
import { LoaderFunction, useLoaderData } from "react-router-dom";
import { Article, fetchArticle, fetchComments, Comment } from "../api";

export const loader: LoaderFunction = async ({ params: { articleId } }) => {
  if (!articleId) {
    throw new Error("No id provided");
  }

  // 記事のコンテンツとコメントどちらも取得が完了するまで待機する
  const [article, comments] = await Promise.all([
    fetchArticle(articleId), // 記事のコンテンツの取得
    fetchComments(articleId), // コメントの取得
  ]);

  return {
    article,
    comments,
  };
};

type LoaderData = {
  article: Article;
  comments: Comment[];
};

export const ArticlePage: React.FC = () => {
  // useloaderData フックで `loader` 関数の戻り値を取得できる
  const { article } = useLoaderData() as LoaderData;

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
      <Comments />
    </div>
  );
};

const Comments: React.FC = () => {
  const { comments } = useLoaderData() as LoaderData;

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.body}</li>
      ))}
    </ul>
  );
};

はじめに loader 関数を定義して export しています。loader 関数は各ルートがレンダリングされる前に呼び出され、return したデータはルートのコンポーネント内で useLoaderData フックを呼び出すことで利用できます。loader 関数によりコンポーネントのレンダリングが開始する間に API のコールを開始でき、データの取得が完了したタイミングでページ遷移をできるようになります。

作成した loader 関数は以下のように createbrowserRouter でルーティングを定義する際に loader プロパティとして渡します。

src/router.tsx
import { createBrowserRouter, RouterProvider, Route } from "react-router-dom";
import { ArticlePage, loader as Articleloader } from "./pages/Article";

export const router = createBrowserRouter([
  {
    path: "/articles/:articleId",
    element: <ArticlePage />,
    loader: Articleloader,
  },
]);

ここでは fetchArticle 関数と fetchComments 関数で API をコールしており、それぞれの関数が解決するまで await で待機してからルートコンポーネントレンダリングを開始することになります。fetchArticle 関数と fetchComments 関数はそれぞれ擬似的にデータの取得が完了するまで 1 秒と 3 秒かかるように設定しています。

それでは実際に試して確認してみましょう。loader 関数により fetchArticlefetchComments のそれぞれの解決を待つ必要があるので、リンクをクリックしてからページ遷移が完了するまで 3 秒かかります。

fetc-article-1

流れを図で表すと以下のようになります。

スクリーンショット 2022-11-12 14.37.34

本来記事のコンテンツを取得するまで 1 秒しかかからないのですが、コメントの取得の完了まで待たなければいけないのでユーザーは 3 秒待たなければ記事のコンテンツを閲覧できません。冒頭で述べたとおり、付随的なデータであるはずのコメントを表示するためにユーザーが余分な時間待機しなければならないのはユーザー体験上好ましくありません。次はコメントデータの取得の完了を待たないで済むように修正してみましょう。

コンポーネント内でコメントデータを取得する

前回は loader 関数内で fetchComments の完了を待たなければいけないために全体的なページの表示に時間がかかってしまっているのでした。そこで loader 関数内で fetchComments を呼び出さないようにしてみましょう。

src/pages/Article.tsx
  export const loader: LoaderFunction = async ({ params: { articleId } }) => {
    if (!articleId) {
      throw new Error("No id provided");
    }

-   const [article, comments] = await Promise.all([
-     fetchArticle(articleId),
-     fetchComments(articleId),
-   ]);
-
-   return {
-     article,
-     comments,
-   };

+   return {
+     article: await fetchArticle(articleId),
+   };
  };

  type LoaderData = {
    article: Article;
-   comments: Comment[];
  };

これで loader 関数では fetchComments 関数の完了を待つ必要がなくなったので、ページ遷移するまでに待機する時間は記事のコンテンツを取得する 1 秒で良くなるはずです。

loader 関数でコメントの取得をしなくなった代わりに、どこか別の方法でコメントを取得する必要があります。ここでは使い古されたパターンを利用しましょう。<Comments /> コンポーネントの useEffect でデータを取得します。

src/pages/Article.tsx
const Comments: React.FC = () => {
  const [comments, setComments] = useState<Comment[] | null>(null);
  const params = useParams();

  useEffect(() => {
    if (!params.articleId) {
      return;
    }
    fetchComments(params.articleId).then((comments) => setComments(comments));
  }, [params.articleId]);

  return (
    <>
      {comments !== null ? (
        <ul>
          {comments.map((comment) => (
            <li key={comment.id}>{comment.body}</li>
          ))}
        </ul>
      ) : (
        <p>Loading Comments...</p>
      )}
    </>
  );
};

それでは試してみましょう。目論見通り、1 秒経過したタイミングで記事のコンテンツが表示されていることがわかります。記事のコンテンツが表示されたタイミングではコメントの取得は完了していないので「Loading Comments...」と表示されます。

fetc-article-2

図で表すと以下のとおりです。

スクリーンショット 2022-11-12 15.04.39

この方法はうまくいっているように思えますが、1 つ問題が生じています。それはコメントの取得は <ArticlePage /> コンポーネントがレンダリングされた後でないと始まらないことです。つまり、ユーザーはページ遷移を開始してから 4 秒待たなければコメントを閲覧できないということになります。

これは useEffect が発火するのはコンポーネントがマウントされるタイミングであることに起因しています。loader 関数内でコメントを取得していたときには、コンポーネントの外でデータの取得をしていたため早いタイミングからデータの取得を開始できていました。しかし、<Comments> コンポーネント内の useEffet でデータを取得するようになったために、記事の取得が完了し <ArticlePage> コンポーネントがレンダリングされなければコメントのデータの取得を開始できなくなってしまいました。

これは Fetch-on-Render と呼ばれるアプローチで、データの取得を開始するまでの他のデータの取得の完了を待たなければいけない問題は「waterfall」と呼ばれています。

defer レスポンスを返す

それでは最後に「waterfall」の問題を解決してみましょう。そのために loader 関数内で defer レスポンスを返すように修正します。早いタイミングでデータの取得を開始できるように fetchComments 関数を loader 関数内で呼び出すように戻してあげます。

src/pages/Article.tsx
export const loader: LoaderFunction = async ({ params: { articleId } }) => {
  if (!articleId) {
    throw new Error("No id provided");
  }
  return defer({
    comments: fetchComments(articleId),
    article: await fetchArticle(articleId),
  });
};

type LoaderData = {
  article: Article;
  comments: Promise<Comment[]>;
};

defer 関数は、解決された値の代わりに Promise を渡すことで、loader から返される値を遅延させることができます。ここで注目すべき点は、fetchArticle 関数には await キーワードを付与しているけれど、fetchComments 関数には await キーワードを付与していない点です。await キーワードを付与するかどうかで遅延させるか決定できます。

遅延したデータを利用するには React Router の提供する <Await> コンポーネントを利用します。遅延された値は resolve Props としてコンポーネントに渡します。<Await> コンポーネントの children は関数となっており、Promise が解決したときその値が引数として渡されます。Promise が reject された場合には errorElement Props の内容を描画します。

また <Await> コンポーネントは Promise が解決されていない場合には Promise を throw するように設計されています。つまり、<Suspense> コンポーネントで囲って使用できるということです。

src/pages/Article.tsx
const Comments: React.FC = () => {
 const { comments } = useLoaderData() as LoaderData;

 return (
   <Suspense fallback={<p>Loading Comments...</p>}>
     <Await resolve={comments} errorElement={<p>Failed to load comments.</p>}>
       {(comments: Comment[]) => (
         <ul>
           {comments.map((comment) => (
             <li key={comment.id}>{comment.body}</li>
           ))}
         </ul>
       )}
     </Await>
   </Suspense>
 );
}; 

それでは実際に試してみましょう。コメントのデータの取得は loader 関数で行われることになったため、取得を開始するまで記事の取得を待つ必要はありません。

fetc-article-3

図で表すと以下のとおりです。

スクリーンショット 2022-11-12 15.53.16

GitHubで編集を提案

Discussion

ログインするとコメントできます