Techtouch Developers Blog

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

React Query のレンダリング最適化を目指した話

adventCalendar2021-day14

エンジニアの macchii です。この記事はテックタッチアドベントカレンダー 14 日目の記事です。

テックタッチでは React を利用して WEB フロントエンドを開発しています。あわせて、リモートデータの取得、更新、キャッシングには React Query を導入しています。本記事では、簡単なタスク管理アプリを題材に、「React Query の再レンダリングを最適化するテクニック」紹介します。

ja.reactjs.org react-query.tanstack.com

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. にチェックを入れると、レンダリングされたコンポーネントがハイライトされるようになります。

chrome.google.com

React Developer Tools の Highlight updates

React Developer Tools を開いて、アプリの Reload ボタンをクリックしてみます。するとデータを取得するたびにレンダリングが発生してしまいました。

デモ: https://react-query-optimization.vercel.app/01 f:id:techtouch:20211206214954g:plain

参照していないプロパティの変更でも再レンダリングが発生する

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.tanstack.com

React Query は、参照していないプロパティの変更でも再レンダリングを発生させます。特に isFetching はリクエストの発行と完了ごとに値が変化するため、取得データの差分に関わらず再レンダリングを発生させてしまいます。

notifyOnChangeProps オプション

今回は data プロパティのみを参照しているため、ほかのプロパティが変化しても、再レンダリングを発生させたくありません。このような場合にレンダリングを最適化するには notifyOnChangeProps オプションを利用できます。notifyOnChangeProps を設定すると、配列で指定したプロパティに変更がある場合のみ、再レンダリングが発生するようになります。

react-query.tanstack.com

実際に notifyOnChangePropsdata プロパティを指定してみます。

// 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 f:id:techtouch:20211206215116g:plain

notifyOnChangePropsdata を指定することで、不要な再レンダリングを抑制することはできました。しかし、この実装には少し不安が残ります。確かにレンダリングは最適化されていますが、指定漏れにより今度は必要な再レンダリングが発生しなくなる恐れがあるからです。

こういったケースのために notifyOnChangePropstracked という値も受け取ることができます。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 内でアクセスされたプロパティを保持していました。

developer.mozilla.org

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];
    },
  });
});

github.com

再度 React Developer Tools で確認してみます。参照プロパティを明示せずに不要なレンダリングを回避することできています!

デモ: https://react-query-optimization.vercel.app/03 f:id:techtouch:20211206215211g:plain

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 f:id:techtouch:20211208031751g:plain

このようなケースには select オプションを利用できます。select オプションを利用すると API レスポンスを加工したり、絞り込んだりできます。

react-query.tanstack.com

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 f:id:techtouch:20211206215509g:plain

まとめ

React Query のレンダリング最適化テクニックをご紹介しました。この記事を執筆中に React Query の v4.0.0-alpha.1 がリリースされたのですが、リリースノートに Tracked Queries per default の記載がありました。細かいテクニックを使わなくてもライブラリがケアしてくれるようになるのは素敵ですね。v4 の正式リリースが楽しみです。

github.com