🦔

useEffectでデータフェッチすると起こる問題

2023/11/02に公開
5

はじめに

アイディオットDX開発部の岩澤です。
とあるプロジェクトで、現在パフォーマンスチューニングを行っています。
いろいろ見ていく中で、フロントエンド側でReactのuseEffectの中でデータフェッチをしてしまうことで起こる問題を発見しました。
せっかくなので、それがなぜ問題を引き起こすのかと、その対策について、今回は記事にしたいと思います。

useEffectの良くない使い方

以下のサンプルコードは、React初級者がよくやりがちな記述です。
useEffectの中で、コンポーネントの初回レンダリング後にデータを取得しています。
また、useStateでローディング状態を管理しています。
そして、Parentsコンポーネントの子コンポーネントとしてChildコンポーネントがあり、Childコンポーネントでも必要なデータをuseEffectの中で取得しています。

parents.tsx
export function Parents() {
  const [loading, setLoading] = useState(true);
  const [parents, setParents] = useState<Parent[]>([])

  useEffect(() => {
    const fetchParents = async () => {
      try {
        const data = await fetchData();
        setParents(data);
      } catch (error) {
        // エラーハンドリング
      } finally {
        setLoading(false);
      }
    };

    fetchParents();
  }, []);


  return (
    <div>
      {
        parents.map((parent) => (
          <div key={parent.id}>
            <h2>{parent.name}</h2>
            <Child parent={parent} />
          <div>
        ))
      }
    </div>
  )
}
child.tsx
export function Child({parent}) {
  const [loading, setLoading] = useState(true);
  const [child, setChild] = useState<Child>()

  useEffect(() => {
    const fetchChild = async () => {
      try {
        const data = await fetchData();
        setChild(data);
      } catch (error) {
        // エラーハンドリング
      } finally {
        setLoading(false);
      }
    };

    fetchChild();
  }, []);


  return (
   ...
  )
}

fetch部分をカスタムフックに切り出して欲しいなぁとかも思ったりするのですが、それは今回の本筋とは関係ないので一旦スルーします。
さて、このコードがどのようなパフォーマンス問題を引き起こすのか。次で見ていきます。

Fetch-on-renderが引き起こす問題

先ほどのuseEffectのコードのように、レンダリング後にデータフェッチすることをFetch-on-renderといいます。
Fetch-on-renderでは、レンダリングが終わらないとデータフェッチが開始されないため、パフォーマンスの悪化につながりやすいです。
先述したコードでは、以下のような流れで処理が走り、最終的にコンポーネントがレンダリングされます。

  1. 初期レンダリング:
    Parentsコンポーネントがレンダリングされ、useStateフックが呼び出されてloadingとparentsの状態変数が初期化されます。

  2. データフェッチ (Parents):
    useEffectフックがトリガーされ、fetchParents関数が非同期で実行されます。この関数は外部から親のデータをフェッチし、成功した場合にはsetParentsを呼び出してparents状態変数を更新します。

  3. 再レンダリング (Parents):
    fetchParents関数がデータをフェッチし、setParentsが呼び出されると、Parentsコンポーネントが再レンダリングされます。

  4. Childコンポーネントのレンダリング:
    Parentsコンポーネントの再レンダリング中に、parents.map関数が各親データに対してChildコンポーネントをレンダリングします。

  5. データフェッチ (Child):
    useEffectがトリガーされ、データフェッチ処理が非同期で実行されます。

  6. 再レンダリング (Child):
    各Childコンポーネントのデータフェッチが成功し、状態が更新されると、Childコンポーネントがそれぞれ再レンダリングされます。

  7. ページ表示:
    すべてのデータフェッチと再レンダリングが完了すると、最終的に完全なコンテンツがユーザーに表示されます。

ご覧の通り、ParentsとChildの両方で、コンポーネントの初期レンダリングが完了してからデータフェッチが開始されています。
データフェッチはレンダリングを待たずして、すぐに始まって欲しいので、あまり効率的とは言えませんね。

パフォーマンス問題への対処方法

では、Fetch-on-renderによるパフォーマンス問題はどのように対処するのが良いでしょうか?
2つの方法を紹介します。

  1. Fetch-then-renderパターン
  2. Render-as-you-fetchパターン

以下で詳しく解説します。

Fetch-then-renderパターン

以下のように、親コンポーネントで子コンポーネント分のデータをフェッチし、子コンポーネントに渡す手法がFetch-then-renderパターンです。

parents.tsx
export function Parents() {
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const parentsData = await fetchParentsData();
        const childData = await fetchChildData(parentsData);
        setData({ parents: parentsData, child: childData });
      } catch (error) {
        // エラーハンドリング
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) {
    return <LoadingIndicator />;
  }

  return (
    <div>
      {data.parents.map((parent, index) => (
          <div key={parent.id}>
            <h2>{parent.name}</h2>
            <Child parent={parent} child={data.child[index]} />
          <div>
      ))}
    </div>
  );
}

以下のような順番で処理が走ります。

  1. Parentsの初期レンダリング完了
  2. useEffectが走り、parentsとchildのデータフェッチ開始
  3. parentsとchildのデータフェッチ完了後、parentsの再レンダリング
  4. Childのレンダリング

ChildでFetch-on-renderしなくてよくなった分、少しだけ改善されました。
しかしながら、この方法は親コンポーネントが肥大化しやすく、Propsのバケツリレーによりコードの可読性も下がるためあまりオススメできません。

Render-as-you-fetchパターン

データフェッチとコンポーネントのレンダリングを同時に開始するパターンになります。
一般的にはSWRやTanStack Queryなどのデータフェッチングライブラリを用いることで実現可能です。
以下の例ではSWRを利用しています。

parents.tsx
export function Parents() {
  const { data: parentsData, error: parentsError } = useSWR(API_PARENTS, fetcher);

  if (parentsError) return <div>Error loading parents data</div>;
  if (!parentsData) return <div>Loading...</div>;

  return (
    <div>
      {parentsData.map((parent) => (
        <div key={parent.id}>
          <h2>{parent.name}</h2>
          <Child parent={parent} />
        <div>
      ))}
    </div>
  );
}

function Child({ parent }) {
  const { data: childData, error: childError } = useSWR(`${API_CHILD + parent.id}`, fetcher);

  if (childError) return <div>Error loading child data</div>;
  if (!childData) return <div>Loading...</div>;

  return (
    <div>
      {/* Render child data */}
      {childData.name}
    </div>
  );
}

以下のような順番で処理が走ります。

  1. Parentsのデータフェッチ開始&ローディング状態をレンダリング
  2. Parentsのデータフェッチが終わったら、Parentsを再レンダリング
  3. Childのデータフェッチ開始&ローディング状態をレンダリング
  4. Childのデータフェッチが終わったら、Childを再レンダリング

Fetch-on-renderと違い、初期レンダリングの完了を待たずして、データフェッチを開始できています。
筆者的には、Fetch-then-renderよりも、それぞれのコンポーネントで必要なデータを取得していてわかりやすいのでおすすめです。

データフェッチングライブラリを使うその他のメリット

Render-as-you-fetchが実現できること以外でも、データフェッチングライブラリは以下のようなパフォーマンス上の利点をもたらしてくれます。
積極的に使っていきましょう。

補足

今回のサンプルコードでは、ChildはParentsのデータに依存しているため、Parentsのデータフェッチを待たないとChildのデータフェッチやレンダリングができません。
このように、データフェッチやレンダリングなど、処理がシーケンシャルに行われることで発生するパフォーマンス問題のことを、ウォーターフォール問題と言います。
ChildでデータフェッチがParentsに依存している以上、データフェッチおけるウォーターフォール問題を回避することはできませんが、Render-as-you-fetchやFetch-then-renderでは、Fetch-on-renderと比較すると、無駄なレンダリングを防ぐという点おいて、ある程度ウォーターフォール問題の軽減にもつながります。
ちなみに、データフェッチのウォーターフォールを回避するとなると、REST API側で返却するデータの設計を見直したり、GraphQLでページに必要なデータを取得するスキーマを定義したりが必要になりそうです。

まとめ

データフェッチにはuseEffectは用いず、SWRなどのデータフェッチングライブラリを用いて、パフォーマンス問題を回避しましょう。

あとがき

AI・データ利活用をリードし、世界にインパクトを与えるプロダクトを開発しませんか?

アイディオットでは、今後の事業拡大及びプロダクト開発を担っていただけるエンジニアチームの強化を行っております。
さらに会社の成長を加速させるため、フロントエンドエンジニア、バックエンドエンジニア、インフラエンジニアのメンバーを募集しております!
日本を代表する企業様へ自社プロダクトを活用した、新規事業コンサルティング、開発にご興味のある方はお気軽にご連絡ください。

【リクルートページ】
https://aidiot.jp/recruit/
【募集ポジション一覧】
https://open.talentio.com/r/1/c/aidiot/homes/3925
【採用についてのお問合せ先】
株式会社アイディオット 採用担当:大島
メールアドレス:recruit@aidiot.jp

Discussion

Honey32Honey32

失礼します。

SWR などのライブラリは、あくまで「render-as-fetch を実現するための機能 e.g. preload を提供している」だけであり、その機能を使わなくても勝手に render-as-fetch を実現できるわけではない、と理解しています。

https://swr.vercel.app/ja/docs/prefetching.ja

また、それ以前に、Child でのデータフェッチに Parent のデータを利用しているので、ウォーターフォールを避けることは不可能なのではないでしょうか?

KK

筆者です。コメントありがとうございます!

SWR などのライブラリは、あくまで「render-as-fetch を実現するための機能 e.g. preload を提供している」だけであり、その機能を使わなくても勝手に render-as-fetch を実現できるわけではない、と理解しています。

シンプルに、レンダリングフェーズでデータフェッチする手法がRender-as-you-fetchであると私は理解しています。
この点において、SWRはコンポーネント内で呼び出すだけで、勝手にRender-as-you-fetchが実現されると解釈しています。
それ以外の、preloadなどの機能はRender-as-you-fetchとはまた別の話かと思います。

また、それ以前に、Child でのデータフェッチに Parent のデータを利用しているので、ウォーターフォールを避けることは不可能なのではないでしょうか?

おっしゃる通り、サンプルコードだとChildのデータを取得するAPIがParentのidに依存しているため、データフェッチにおいてはウォーターフォールを避けることはできないないです。
文章が誤解を与える表現となっていたため、補足の章でその旨追記する形で修正しました。
ご指摘ありがとうございますmm

Honey32Honey32

Suspense を使っていない SWR において、フェッチ処理は普通に useIsomorphicEffect を使ってレンダリングの直後に開始しています。

https://github.com/vercel/swr/blob/41b061306e2ce8b64e2923b1594e8db525c9368a/core/src/use-swr.ts#L620-L629

https://github.com/vercel/swr/blob/41b061306e2ce8b64e2923b1594e8db525c9368a/_internal/src/utils/env.ts#L19


https://zenn.dev/yumemi_inc/articles/react-lifetime-of-variable

「Fetch-then-render または Render-as-you-fetch」と「Fetch-on-render」 の間にある違いは、「フェッチ処理の開始がコンポーネントの最初の描画より先か後か」だと認識していますが、

子コンポーネントで使うデータを、そのコンポーネントのレンダリングより先に開始しようと思ったら、親コンポーネントかファイル直下(コンポーネントの外)で呼び出さないといけません。なので親またはトップレベルで preload を使って初めて Fetch-on-render を脱することができます。

Honey32Honey32

論より証拠なので、実際に検証した結果を貼っておきます。

これで、useSWR だけを使って preload しなかった場合には、fetcher 関数が初めて実行されるタイミングが初期レンダリングの直後であることが分かると思います。

コンソールの出力
render Parent (undefined)
installHook.js:1 render Parent (undefined)
fetcher.ts:6 fetching started : parent
fetcher.ts:8 fetching completed
page.tsx:12 render Parent ({"data":"parent"})
page.tsx:12 render Parent ({"data":"parent"})
Child.tsx:9 render Child (undefined)
Child.tsx:9 render Child (undefined)
fetcher.ts:6 fetching started : child
fetcher.ts:8 fetching completed
Child.tsx:9 render Child ({"data":"child"})
Child.tsx:9 render Child ({"data":"child"})
fetcher.ts
import { Fetcher } from "swr";

export const fetcher: Fetcher<{ data: string }, string> = async (
  key: string
) => {
  console.log("fetching started : " + key);
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log("fetching completed");
  return { data: key };
};

page.tsx
"use client";

import { FC } from "react";
import useSWR from "swr";
import { fetcher } from "./fetcher";
import { Child } from "./Child";

const Page: FC = () => {
  const { data } = useSWR("parent", fetcher);
  console.log(`render Parent (${JSON.stringify(data)})`);
  return (
    <div>
      <div>{data?.data}</div>
      {data && <Child />}
    </div>
  );
};

export default Page;

Child.tsx
"use client";

import useSWR, { Fetcher } from "swr";
import { fetcher } from "./fetcher";

export const Child = () => {
  const { data } = useSWR("child", fetcher);

  console.log(`render Child (${JSON.stringify(data)})`);

  return <div>{data?.data}</div>;
};

KK

なんと!誤った理解をしておりましたmm
preloadを使ったことによってRender-as-you-fetchが実現できていたのですね。
ソースコードや検証結果を元に正確な情報をわかりやすく教えていただきとても助かりました。
のちほど記事も訂正しておきます。