Movatterモバイル変換


[0]ホーム

URL:


Techtouch Developers Blog

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

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

adventCalendar2021-day14

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

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

ja.reactjs.orgreact-query.tanstack.com

TL;DR

  • React Query は取得データを厳密に比較(deep compare)し、変更がある場合のみ再レンダリングします
  • notifyOnChangeProps オプションで、再レンダリングの対象となるプロパティを限定できます
  • select オプションで絞り込んだデータ同士を比較しよう
  • v4 リリースが待ち遠しい

はじめに

題材は React Query でリモートからタスクを取得し、画面に表示するだけのシンプルなアプリです。Reload ボタンをクリックすると React Query のキャッシュをinvalidate し、データを再取得します。

タスク管理アプリ

ページのコンポーネントは以下の通りです。

// src/pages/01.tsximporttype{ NextPage}from"next";importtype{ VFC}from"react";import{ useCallback}from"react";import{ useQuery, useQueryClient}from"react-query";import{ getTasks}from"../api/get-tasks";importtype{ 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(<buttontype="button"onClick={handleClick}>      Reload</button>);};const Page: NextPage=()=>(<><TaskList /><ReloadButton /></>);exportdefault Page;

ソースコードの全体は、以下のリポジトリに保持しています。github.com

タスクの API レスポンスは常に同じ値を返すようにしました。不要な再レンダリングを確認しやすくするためです。

// src/api/get-tasks.tsimporttype{ NextApiRequest, NextApiResponse}from"next";importtype{ Task}from"../../domain/task";exporttype 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);};exportdefault 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/01f: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.tsxconst 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/02f:id:techtouch:20211206215116g:plain

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

こういったケースのためにnotifyOnChangePropstracked という値も受け取ることができます。tracked を指定すると React Query が自動で参照されているプロパティを追跡してくれます。

// src/pages/03.tsxconst 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(keyaskeyof QueryObserverResult);return result[keyaskeyof QueryObserverResult];},});});

github.com

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

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

select オプション

次はタスクに加えてラベルを表示する UI を考えてみます。ラベルデータをリモートから取得し、各タスクがラベル ID で必要なラベルデータ選択し、画面に表示します。

タスク一覧 + ラベル

// src/pages/04.tsximporttype{ NextPage}from"next";importtype{ 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";importtype{ 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(<buttontype="button"onClick={handleClick}>      Reload</button>);};const Page: NextPage=()=>(<><TaskList /><ReloadButton /></>);exportdefault Page;

ラベルの API は、リストの最後の値が毎回ランダムに変わるように細工してあります。

// src/api/get-labels.tsimporttype{ NextApiRequest, NextApiResponse}from"next";importtype{ Label}from"../../domain/label";exporttype 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());};exportdefault handler;

ラベルの API レスポンスが毎回変化するため、notifyOnChangeProps を設定しても、データを取得するたびに再レンダリングが発生するようになりました。しかし、タスクが参照するラベルには変化がないため、レンダリングが無駄になってしまっています。

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

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

react-query.tanstack.com

React Query はデフォルトだと取得データを比較しますが、select オプションが設定されている場合はselect の戻り値を比較するようになります。そのため取得データに変更があったとしても、必要なデータに変更がなければ再レンダリングは発生しなくなります。

// src/pages/05.tsxconst 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/05f:id:techtouch:20211206215509g:plain

まとめ

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

github.com

We're Hiring!!

テックタッチは、「Maximize the power of tech 〜テックの力を最大化する〜」というミッションを掲げています。誰もがシステムを使いこなせる世界を、私たちと一緒につくりませんか。

採用情報はこちらTechtouch Developers Blog 読者になる

techtouch 読者になる

この記事をはてなブックマークに追加
We are hiring! 採用情報はこちら
RSS

引用をストックしました

引用するにはまずログインしてください

引用をストックできませんでした。再度お試しください

限定公開記事のため引用できません。

読者です読者をやめる読者になる読者になる

[8]ページ先頭

©2009-2025 Movatter.jp