エンジニアの macchii です。この記事はテックタッチアドベントカレンダー 14 日目の記事です。
テックタッチでは React を利用して WEB フロントエンドを開発しています。あわせて、リモートデータの取得、更新、キャッシングには React Query を導入しています。本記事では、簡単なタスク管理アプリを題材に、「React Query の再レンダリングを最適化するテクニック」紹介します。
ja.reactjs.org react-query.tanstack.com
- TL;DR
- はじめに
- React Query は取得データを厳密に比較(deep compare)する
- 参照していないプロパティの変更でも再レンダリングが発生する
- notifyOnChangeProps オプション
- select オプション
- まとめ
TL;DR
- React Query は取得データを厳密に比較(deep compare)し、変更がある場合のみ再レンダリングします
notifyOnChangeProps
オプションで、再レンダリングの対象となるプロパティを限定できますselect
オプションで絞り込んだデータ同士を比較しよう- v4 リリースが待ち遠しい
はじめに
題材は React Query でリモートからタスクを取得し、画面に表示するだけのシンプルなアプリです。Reload ボタンをクリックすると React Query のキャッシュを invalidate
し、データを再取得します。
ページのコンポーネントは以下の通りです。
// src/pages/01.tsx import type { NextPage } from "next"; import type { VFC } from "react"; import { useCallback } from "react"; import { useQuery, useQueryClient } from "react-query"; import { getTasks } from "../api/get-tasks"; import type { Task } from "../domain/task"; const TaskItem: VFC<{ task: Task }> = ({ task }) => <li>{task.name}</li>; const TaskList: VFC = () => { const { data: tasks } = useQuery("tasks", getTasks); return ( <ul> {tasks?.map((task) => ( <TaskItem key={task.id} task={task} /> ))} </ul> ); }; const ReloadButton: VFC = () => { const queryClient = useQueryClient(); const handleClick = useCallback(() => { queryClient.invalidateQueries("tasks"); }, [queryClient]); return ( <button type="button" onClick={handleClick}> Reload </button> ); }; const Page: NextPage = () => ( <> <TaskList /> <ReloadButton /> </> ); export default Page;
ソースコードの全体は、以下のリポジトリに保持しています。 github.com
タスクの API レスポンスは常に同じ値を返すようにしました。不要な再レンダリングを確認しやすくするためです。
// src/api/get-tasks.ts import type { NextApiRequest, NextApiResponse } from "next"; import type { Task } from "../../domain/task"; export type Data = Task[]; const MOCK_DATA: Data = [ { id: 1, name: "部屋を掃除する", labelIds: [], }, { id: 2, name: "買い出しに行く", labelIds: [1, 2], }, { id: 3, name: "お花に水をあげる", labelIds: [2, 3], }, ]; const handler = (req: NextApiRequest, res: NextApiResponse<Data>) => { // 常に同じ値を返却する res.status(200).json(MOCK_DATA); }; export default handler;
React Query は取得データを厳密に比較(deep compare)する
React Query は取得データを厳密に比較(deep compare)し、変更がある場合のみ再レンダリングします。 react-query-v2.tanstack.com
今回はタスクの API レスポンスが常に同一です。それならば再レンダリングは発生しないように思えます。
実際に確認してみましょう。React Developer Tools を利用します。Chrome DevTools の Components → General → Highlight updates when components render. にチェックを入れると、レンダリングされたコンポーネントがハイライトされるようになります。
React Developer Tools を開いて、アプリの Reload ボタンをクリックしてみます。するとデータを取得するたびにレンダリングが発生してしまいました。
デモ: https://react-query-optimization.vercel.app/01
参照していないプロパティの変更でも再レンダリングが発生する
useQuery の戻り値には data
以外にもたくさんのプロパティが含まれています。よく利用するのは error
, isLoading
あたりでしょうか。
const { data, dataUpdatedAt, error, errorUpdatedAt, failureCount, isError, isFetched, isFetchedAfterMount, isFetching, isIdle, isLoading, isLoadingError, isPlaceholderData, isPreviousData, isRefetchError, isRefetching, isStale, isSuccess, refetch, remove, status, } = useQuery(queryKey, queryFn, {});
React Query は、参照していないプロパティの変更でも再レンダリングを発生させます。特に isFetching
はリクエストの発行と完了ごとに値が変化するため、取得データの差分に関わらず再レンダリングを発生させてしまいます。
notifyOnChangeProps
オプション
今回は data
プロパティのみを参照しているため、ほかのプロパティが変化しても、再レンダリングを発生させたくありません。このような場合にレンダリングを最適化するには notifyOnChangeProps
オプションを利用できます。notifyOnChangeProps
を設定すると、配列で指定したプロパティに変更がある場合のみ、再レンダリングが発生するようになります。
実際に notifyOnChangeProps
に data
プロパティを指定してみます。
// src/pages/02.tsx const TaskList: VFC = () => { const { data: tasks } = useQuery("tasks", getTasks, { // data プロパティのみを指定 notifyOnChangeProps: ["data"], }); return ( <ul> {tasks?.map((task) => ( <TaskItem key={task.id} task={task} /> ))} </ul> ); };
再度 React Developer Tools で確認すると、今度はデータを再取得しても再レンダリングが発生しなくなりました!
デモ: https://react-query-optimization.vercel.app/02
notifyOnChangeProps
に data
を指定することで、不要な再レンダリングを抑制することはできました。しかし、この実装には少し不安が残ります。確かにレンダリングは最適化されていますが、指定漏れにより今度は必要な再レンダリングが発生しなくなる恐れがあるからです。
こういったケースのために notifyOnChangeProps
は tracked
という値も受け取ることができます。tracked
を指定すると React Query が自動で参照されているプロパティを追跡してくれます。
// src/pages/03.tsx const TaskList: VFC = () => { const { data: tasks } = useQuery("tasks", getTasks, { // tracked を指定 notifyOnChangeProps: "tracked", }); return ( <ul> {tasks?.map((task) => ( <TaskItem key={task.id} task={task} /> ))} </ul> ); };
追跡には Object.defineProperty()を利用しているようです。getter 内でアクセスされたプロパティを保持していました。
Object.keys(result).forEach((key) => { Object.defineProperty(trackedResult, key, { configurable: false, enumerable: true, get: () => { trackProp(key as keyof QueryObserverResult); return result[key as keyof QueryObserverResult]; }, }); });
再度 React Developer Tools で確認してみます。参照プロパティを明示せずに不要なレンダリングを回避することできています!
デモ: https://react-query-optimization.vercel.app/03
select
オプション
次はタスクに加えてラベルを表示する UI を考えてみます。ラベルデータをリモートから取得し、各タスクがラベル ID で必要なラベルデータ選択し、画面に表示します。
// src/pages/04.tsx import type { NextPage } from "next"; import type { VFC } from "react"; import { useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "react-query"; import { getLabels } from "../api/get-labels"; import { getTasks } from "../api/get-tasks"; import type { Task } from "../domain/task"; const TaskItem: VFC<{ task: Task }> = ({ task }) => { const { data } = useQuery("labels", getLabels, { notifyOnChangeProps: "tracked", }); const labels = useMemo( () => data?.filter(({ id }) => task.labelIds.includes(id)), [data, task.labelIds] ); return ( <li> {task.name} {labels?.map((label) => ( <small key={label.id}>{label.name}</small> ))} </li> ); }; const TaskList: VFC = () => { const { data: tasks } = useQuery("tasks", getTasks, { notifyOnChangeProps: "tracked", }); return ( <ul> {tasks?.map((task) => ( <TaskItem key={task.id} task={task} /> ))} </ul> ); }; const ReloadButton: VFC = () => { const queryClient = useQueryClient(); const handleClick = useCallback(() => { queryClient.invalidateQueries("tasks"); queryClient.invalidateQueries("labels"); }, [queryClient]); return ( <button type="button" onClick={handleClick}> Reload </button> ); }; const Page: NextPage = () => ( <> <TaskList /> <ReloadButton /> </> ); export default Page;
ラベルの API は、リストの最後の値が毎回ランダムに変わるように細工してあります。
// src/api/get-labels.ts import type { NextApiRequest, NextApiResponse } from "next"; import type { Label } from "../../domain/label"; export type Data = Label[]; const getMockData = (): Data => [ { id: 2, name: "ルーティーン", }, { id: 1, name: "すぐやる", }, { id: 3, name: "空き時間にやる", }, { id: 4, // name をリクエストごとに変更する name: `ランダム ${Math.random()}`, }, ]; const handler = (req: NextApiRequest, res: NextApiResponse<Data>) => { res.status(200).json(getMockData()); }; export default handler;
ラベルの API レスポンスが毎回変化するため、notifyOnChangeProps
を設定しても、データを取得するたびに再レンダリングが発生するようになりました。しかし、タスクが参照するラベルには変化がないため、レンダリングが無駄になってしまっています。
デモ: https://react-query-optimization.vercel.app/04
このようなケースには select
オプションを利用できます。select オプションを利用すると API レスポンスを加工したり、絞り込んだりできます。
React Query はデフォルトだと取得データを比較しますが、select
オプションが設定されている場合は select
の戻り値を比較するようになります。そのため取得データに変更があったとしても、必要なデータに変更がなければ再レンダリングは発生しなくなります。
// src/pages/05.tsx const TaskItem: VFC<{ task: Task }> = ({ task }) => { const { data: labels } = useQuery("labels", getLabels, { notifyOnChangeProps: "tracked", // 必要なラベルにフィルタリング select: (data) => data.filter(({ id }) => task.labelIds.includes(id)), }); return ( <li> {task.name} {labels?.map((label) => ( <small key={label.id}>{label.name}</small> ))} </li> ); };
確認してみます。不要なレンダリングが発生していないことがわかります!
デモ: https://react-query-optimization.vercel.app/05
まとめ
React Query のレンダリング最適化テクニックをご紹介しました。この記事を執筆中に React Query の v4.0.0-alpha.1
がリリースされたのですが、リリースノートに Tracked Queries per default
の記載がありました。細かいテクニックを使わなくてもライブラリがケアしてくれるようになるのは素敵ですね。v4
の正式リリースが楽しみです。