
エンジニアのmacchii です。この記事はテックタッチアドベントカレンダー 14 日目の記事です。
テックタッチでは React を利用して WEB フロントエンドを開発しています。あわせて、リモートデータの取得、更新、キャッシングには React Query を導入しています。本記事では、簡単なタスク管理アプリを題材に、「React Query の再レンダリングを最適化するテクニック」紹介します。
ja.reactjs.orgreact-query.tanstack.com
notifyOnChangeProps オプションで、再レンダリングの対象となるプロパティを限定できますselect オプションで絞り込んだデータ同士を比較しよう題材は 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-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.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/02
notifyOnChangeProps にdata を指定することで、不要な再レンダリングを抑制することはできました。しかし、この実装には少し不安が残ります。確かにレンダリングは最適化されていますが、指定漏れにより今度は必要な再レンダリングが発生しなくなる恐れがあるからです。
こういったケースのためにnotifyOnChangeProps はtracked という値も受け取ることができます。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 内でアクセスされたプロパティを保持していました。
Object.keys(result).forEach((key)=>{Object.defineProperty(trackedResult, key,{ configurable:false, enumerable:true,get:()=>{ trackProp(keyaskeyof QueryObserverResult);return result[keyaskeyof QueryObserverResult];},});});
再度 React Developer Tools で確認してみます。参照プロパティを明示せずに不要なレンダリングを回避することできています!
デモ:https://react-query-optimization.vercel.app/03
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/04
このようなケースにはselect オプションを利用できます。select オプションを利用すると API レスポンスを加工したり、絞り込んだりできます。
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/05
React Query のレンダリング最適化テクニックをご紹介しました。この記事を執筆中に React Query のv4.0.0-alpha.1 がリリースされたのですが、リリースノートにTracked Queries per default の記載がありました。細かいテクニックを使わなくてもライブラリがケアしてくれるようになるのは素敵ですね。v4 の正式リリースが楽しみです。
id:tocomi
id:shoco55
id:pochipochi2X5
id:tabito-hara
id:okikutan
id:techtouch
id:cobalt_catal
id:taisa831
id:sok14
id:analogrecord
id:izzii
id:alluser
id:lexiko
id:yhoriuchi
id:daryo
id:murochi
id:mikatyy
id:komo-ri
id:homemade-ramen
id:toukoudo
id:tkr911
id:shuwccf
id:ohwatoshiyuki
id:keita03030303
id:shzawa
id:yamanoi-y
id:makky_tyuyan
id:ihirokyx
id:tt_kacchan引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。