テックタッチアドベントカレンダー 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
経由でアクセスされたプロパティのみを監視対象にしているからです。
より詳しい説明は以下を見ていただければと思います。
しかし使い方次第ではこの機能の恩恵を受けられないことがありますので、いくつかのポイントを抑えておく必要があります。
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 などがあるので、機会があればぜひ使っていきたいと思います。