waterfall

React Router の defer で重要なデータを先に描画する

あるページの中で重要ではない付随的なデータの取得を待たずに、重要なデータの取得が完了したタイミングでページを表示させたい場合があります。例えば、ブログの記事のページを遷移する場合、ユーザーにとって記事のコンテンツは重要なデータですが、それに付随するコメントやいいねの数はそれほど重要ではないので、それらのデータの取得を待つ必要がありません。 この記事では React Router の loaderを使用して重要なデータの完了のみを待機する方法を試してみます。

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

この記事では React Router の loader を使用して重要なデータの完了のみを待機する方法を試してみます。

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

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

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 = () => {
  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 をコールでき、データの取得が完了したタイミングでレンダリングを開始できるようになります。

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

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

fetc-article-1

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

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

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

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

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

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

const Comments: React.FC = () => {
  const [comments, setComments] = useState<Comment[]>([]);
  const params = useParams();
 
  useEffect(() => {
    if (!params.articleId) {
      return;
    }
    fetchComments(params.articleId).then((comments) => setComments(comments));
  }, [params.articleId]);
 
  return (
    <>
      {comments.length > 0 ? (
        <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 秒待たなければコメントを閲覧できないということになります。これは useEffetc が発火するのはコンポーネントがマウントされるタイミングであることに起因しています。loader 関数内でコメントを取得していたときには、コンポーネントの外でデータの取得をしていたため早いタイミングからデータの取得を開始できていたのですが、<Comments> コンポーネント内の useEffet でデータを取得するようになったために、記事の取得が完了しないとコメントのデータの取得を開始できなくなってしまいました。

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

defer レスポンスを返す

それでは最後に「waterfall」の問題を解決してみましょう。そのために loader 関数内で defer レスポンスを返すように修正します。

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 関数は、解決された値の代わりにプロミスを渡すことで、loader から返される値を遅延させることができます。ここで注目すべき点は、fetchArticle 関数には await キーワードを付与しているけれど、fetchComments 関数には await キーワードを付与していない点です。React Router により await キーワードを付与するかどうかで自動的に遅延させるかどうか決定させることができます。

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

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

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


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事