Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

TanStack Query (react query) でパフォーマンス問題に遭遇したので解決してみた

テックタッチアドベントカレンダー 12 日目を担当する、フロントエンドエンジニアの taka です。

今回は TanStack Query を利用する上でパフォーマンスの問題に遭遇してしまったので、その内容について共有したいと思います。

tl;dr

  • useQueryの返り値を非同期処理内で参照してしまっていたのが原因だった
  • react query でパフォーマンス問題に陥らないために、useQuery を利用する際は以下のポイントを抑えておきましょう
    • 返り値のobject rest destructuring を避ける
    • 非同期処理内で返り値の値を参照するのを避ける
    • 必要であれば selector で参照する値を絞る

TanStack Query (react query) とは

tanstack.com TanStack Query は非同期な状態を管理するためのライブラリです。このライブラリを使うことで非同期処理を宣言的に書くことができ、より React と親和性が高いコードを記述することができます。テックタッチでは主にネットワークを介したデータの管理に利用しています。

もともとは react query という名前でしたが、SolidJS や Vue などにも対応するようになったこともあり、v4 からは TanStack Query という名前に変更されています。npm package も react-query から @tanstack/react-query に変更されています。

今回は React のみにフォーカスを当てるため、 以降は react query と呼びます。

遭遇したパフォーマンス問題

大量のデータを取得し、それらをリスト表示するようなコンポーネントでパフォーマンス問題に遭遇しました。 このコンポーネントでは以下のようなことを行なっています。

  • トークンを useQuery 経由で取得する
  • @tanstack/react-virtual を使ってデータをバーチャルリストとして表示する
  • リストアイテムの内容を useQuery 経由でトークンを利用して都度取得する

バーチャルリストとは、大量のデータに対して少量のコンポーネントのみを描画することで、仮想的に大量のコンポーネントが描画されているように見せるリストのことです。 これにより、余分なコンポーネントの描画を抑制することができ、 DOM の数を削減し初回描画を早められたり通信量を削減したりできます。

実際の動きは以下です。

修正前

count はリストアイテムの描画回数を表しています。 リストアイテムは初回描画後に非同期でデータを取得し再度描画するので、合計 2 回描画されるのが期待値なのですが、スクロールする度に何度も再描画されてしまっていることがわかります。

コードは以下です。

import {
  useQuery,
  QueryClientProvider,
  QueryClient,
} from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useRef } from "react";

const Counter = () => {
  const ref = useRef(0);
  return <span>render count={++ref.current}</span>;
};

const useToken = () => {
  return useQuery(["token"], () => "token");
};

const ListItem = memo(({ index }: { index: number }) => {
  const data = useToken();
  const item = useQuery(["item", index], async () => {
    return new Promise<string>((resolve) =>
      setTimeout(() => {
        resolve(`${data.data}${index}`);
      }, 100)
    );
  });
  return (
    <div>
      index={index} <Counter /> {item.data}
    </div>
  );
});

const List = () => {
  const parentRef = useRef<HTMLDivElement | null>(null);
  const rowVirtualizer = useVirtualizer({
    count: 10000,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 5,
  });
  const { data } = useToken();
  return data ? (
    <div
      ref={parentRef}
      className="List"
      style={{
        height: `400px`,
        width: `400px`,
        overflow: "auto",
      }}
    >
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: "100%",
          position: "relative",
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
              backgroundColor: virtualRow.index % 2 ? "#e6e4dc" : "",
            }}
          >
            <ListItem index={virtualRow.index} />
          </div>
        ))}
      </div>
    </div>
  ) : null;
};

const client = new QueryClient();
export default function App() {
  return (
    <QueryClientProvider client={client}>
      <List />
    </QueryClientProvider>
  );
}

code sandboxはこちらです。

react-query-performance-issue - CodeSandbox

どこが悪いかわかりますでしょうか?

先に結論を言ってしまうと、リストアイテムのデータを取得する際にuseQuery の返り値である token.dataへの参照を非同期で行なってしまっていることが原因でした。

上記のListItemを以下のように書き換えることで正しく動作するようになります。

const ListItem = memo(({ index }: { index: number }) => {
  const { data } = useToken();
  const item = useQuery(["item", index], async () => {
    return new Promise<string>((resolve) =>
      setTimeout(() => {
        resolve(`${data}${index}`);
      }, 100)
    );
  });
  return (
    <div>
      index={index} <Counter /> {item.data}
    </div>
  );
});

修正後は以下のように、全てのリストアイテムの描画回数が2回になっていることがわかるかと思います。

修正後

react query で再描画を抑制するためのポイント

react query v4 からは、非同期処理の結果に変更がない場合は不要な再描画が発生しないようになっています。 これは、useQuery の返り値に Object.defineProperty() を利用することでgetter 経由でアクセスされたプロパティのみを監視対象にしているからです。 より詳しい説明は以下を見ていただければと思います。

tech.techtouch.jp

しかし使い方次第ではこの機能の恩恵を受けられないことがありますので、いくつかのポイントを抑えておく必要があります。

object rest destructuring を利用してはいけない

これを使ってしまうと全てのプロパティが監視対象になってしまうため、再描画が発生してしまうようになります。

// 🚨 返り値を一切参照していない
useQuery(...)

// 🚨 返り値全てを参照してしまっている
const queryInfo = useQuery(...)
console.log(queryInfo)

// 🚨 全ての値を監視してしまう
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ 必要な値のみ監視する
const { isLoading, data } = useQuery(...)

プロパティ参照は描画中に行う

描画時にuseQueryの返り値のプロパティへに対しての参照が発生しないと、 react query は監視すべきプロパティがわかりません。 そのため、非同期で参照するような場合は正しく動作しません。

const queryInfo = useQuery(...)

// 🚨 非同期の中で参照すると正しく動作しない
React.useEffect(() => {
    console.log(queryInfo.data)
})

// ✅ `queryInfo.data`が描画時に依存配列の中で参照されるので正しく動作する
React.useEffect(() => {
    console.log(queryInfo.data)
}, [queryInfo.data])

selector を使って必要なデータのみを参照する

今回は利用しませんでしたが、react query には他にもパフォーマンスを改善するための仕組みが用意されています。 selectorを利用することで、特定の値のみを取得・監視することができます。 以下の例ではuserデータからusernameのみを取り出しているのですが、fetchUserのレスポンスが変わったとしてもusernameが変わった時のみ再描画されます。

import { useQuery } from "react-query";

function User() {
  const { data } = useQuery(["user"], fetchUser, {
    select: (user) => user.username,
  });
  return <div>Username: {data}</div>;
}

これらは以下のサイトを参考にさせていただきました。 tkdodo.eu

おわりに

react query はとても便利なライブラリです。今回利用した TanStack Virtual もとても便利で、 TanStack にはとてもお世話になっています。 TanStack は他にも TanStack Table や TanStack Router などがあるので、機会があればぜひ使っていきたいと思います。