TanStack QueryにおけるuseQueryのselectオプションを調べてみた

TanStack Queryの useQuery のドキュメントを見ていたところ、 select オプションがありました。

This option can be used to transform or select a part of the data returned by the query function. It affects the returned data value, but does not affect what gets stored in the query cache.

https://tanstack.com/query/v4/docs/react/reference/useQuery

 
select オプションの挙動について調べたところ、以下のBlogに記載がありました。
React Query のレンダリング最適化を目指した話 - Techtouch Developers Blog

そこで、上記のBlogを参考にしつつ、気になったところを調べてみたときのメモを残します。

 
目次

 

環境

  • React 18.2.0
  • TanStack Query 4.32.6

環境は以前の記事と同じため、詳しくはそちらを参照してください。
Reactにて、useStateやuseEffectを使っていたところをTanStack Queryに置き換えてみた - メモ的な思考的な

 

selectを使ったデータの絞り込み

useQueryAPIから取得したデータを絞り込む場合、 useQueryの

  • queryFn に定義した関数の then の中で絞り込む
  • select に定義した関数にて絞り込む

の2つの方法が考えられました。

そこで、今回「バックエンドから取得したデータのうち、最初のデータに絞り込む」という実装を、各パターンでためしてみます。

 
まずは queryFn で絞り込む場合です。このときは then の中で絞り込みを行います。

// thenの中で絞り込みを行う
const queryWithFilter = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes[0])

const Page = () => {
  // useQueryでは絞り込みを行わない
  const {data: queryFnData} = useQuery({
    queryKey: ['DataFilter', 'ByQueryFn'],
    queryFn: queryWithFilter,
  })
// ...
}

 
一方、 select で絞り込む場合は、 queryFn の関数では特に絞り込みを行わず、 select で指定した関数の方で絞り込みます。

// 絞り込みを行わない
const queryWithoutFilter = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes)

const Page = () => {
  // 
  const {data: selectData} = useQuery({
    queryKey: ['DataFilter', 'BySelect'],
    queryFn: queryWithoutFilter,
    select: (fishes) => {
      return fishes[0]
    },
  })
// ...
}

 
では、実際にコンポーネントへ組み込み、動作確認してみます

import {useQuery} from "@tanstack/react-query";
import {DefaultApi} from "../../../../../types";

const queryWithFilter = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes[0])
const queryWithoutFilter = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes)

const Page = () => {
  const {data: queryFnData} = useQuery({
    queryKey: ['DataFilter', 'ByQueryFn'],
    queryFn: queryWithFilter,
  })

  const {data: selectData} = useQuery({
    queryKey: ['DataFilter', 'BySelect'],
    queryFn: queryWithoutFilter,
    select: (fishes) => {
      return fishes[0]
    },
  })

  if (!queryFnData || !selectData) return <>No data...</>

  return (
    <>
      <h1>結果(queryFnでフィルター)</h1>
      <p>ID: {queryFnData.id} / Name: {queryFnData.name}</p>

      <hr />

      <h1>結果(selectでフィルター)</h1>
      <p>ID: {selectData.id} / Name: {selectData.name}</p>
    </>
  )
}

export default Page

 
すると、両方とも、最初のデータだけの絞り込みができました。

 

selectで絞り込むと再レンダリングされない

queryFnselect のどちらでも絞り込むことができました。

次に、それらの違いは何かを見てみます。

冒頭の記事によると、

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

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

とあったため、ためしてみます。

 

準備: React Dev Toolsの Highlight updates when components render をON

今回、再レンダリングされることを確認します。

そこで、React Dev Toolsの Components タブにある Highlight updates when components render. にチェックを入れて、再レンダリングの発生をわかりやすくしておきます。

 

準備:PrismでArrayのレスポンス件数を固定化する

今回はPrismでFakeデータを返しているため、何も設定しないとAPIからレスポンスされるデータ件数が変化してしまいます。

その結果、再レンダリングの発生が

  • データ件数が変化したため
  • selectを使っていないため

のどちら由来かが分かりづらいです。

そこで、OpenAPIスキーマにPrism用の設定を追加し、レスポンスデータの件数を固定化します。

 
Prisimのドキュメントによると、OpenAPIスキーマの冒頭に x-json-schema-faker を定義すれば良さそうでした。
Dynamic Response Generation with Faker | Prism

そこで、今回はレスポンスの件数を3件で固定化するよう、設定を追加しました。

openapi: 3.0.0
x-json-schema-faker:
  min-items: 3
  max-items: 3

 
なお、この設定はグローバルなため、全APIのレスポンスへ影響してしまいます。

ただ、今回の検証するにあたっては問題にはならないので、気にせず進めます。

 

selectオプションなしの場合

最初に、 queryFnAPIを呼ぶ関数を定義しておきます。

次に、コンポーネントを用意します。コンポーネントの中では

  • <p>{data?.length}件あります</p> のようにして、APIレスポンスを編集・表示
  • リロードボタンを押すと、TanStack Queryの refetch が実行され、APIが再度呼ぶ

という実装にします。

import {useQuery} from "@tanstack/react-query";
import {DefaultApi} from "../../../../../../types";

const queryFn = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes)

const Page = () => {
  const {data, refetch} = useQuery({
    queryKey: ['DataFilter'],
    queryFn: queryFn,
  })

  const handleClick = () => {
    refetch()
  }

  return (
    <>
      <h1>結果(queryFnの結果をフィルター)</h1>
      <p>{data?.length}件あります</p>

      <hr />
      <button type={"button"} onClick={handleClick}>リロード</button>
    </>
  )
}

export default Page

 
実装ができたので、動作を確認します。

すると、ボタンを押したタイミングで再レンダリングが発生しました。

 

selectオプションありの場合

続いて、 select オプションありの場合です。

違いは useQuery の引数だけです。 select オプションの中で件数を算出してしまいます。

今回の場合、APIのレスポンス件数は3件で固定なため、この設定だと再レンダリングは発生しない想定です。

const {data, refetch} = useQuery({
  queryKey: ['DataFilter'],
  queryFn: queryFn,
  select: (fishes) => {
    return fishes.length
  },
})

 
動作を確認してみると、何度ボタンをクリックしても再レンダリングは発生しませんでした。

 

selectで絞り込んでも再レンダリングされるケース

select オプションを指定することで再レンダリングを防げました。

ただ、 useQuery では、参照しているプロパティに変更がある場合、再レンダリングが発生します。

そのため、どうしても再レンダリングを防止するには、参照しているプロパティのことも考える必要があります。

そこで今回は

  • data 以外にも参照しているプロパティを追加して、再レンダリングされる様子を確認
  • 上記の実装でも、再レンダリングを防ぐ方法を確認

を行ってみます。

 

dataとisRefetchingを参照し、selectで絞り込むだけでは再レンダリングが発生する

useQuery には数多くのプロパティがあります。
https://tanstack.com/query/v4/docs/react/reference/useQuery

今回は、 data の他に isRefetching への参照を追加し、再レンダリングが発生する様子を見ていきます。

そのための差分は、

  • const {data, refetch, isRefetching} = useQuery({}) で、 isRefetching の参照を追加する
  • <p>isRefetching: {isRefetching.toString()}</p> を追加し、 isRefetching の値を確認できるようにする

です。

また、ソースコード全体は以下です。

import {useQuery} from "@tanstack/react-query";
import {DefaultApi} from "../../../../../../types";

const queryFn = () =>  new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then((response) => response.data.fishes)

const Page = () => {
  const {data, refetch, isRefetching} = useQuery({
    queryKey: ['DataFilter'],
    queryFn: queryFn,
    select: (fishes) => {
      return fishes.length
    },
  })

  const handleClick = () => {
    refetch()
  }

  return (
    <>
      <h1>結果(selectでフィルター、isRefetchingあり)</h1>
      <p>isRefetching: {isRefetching.toString()}</p>
      <p>{data}件あります</p>

      <hr />
      <button type={"button"} onClick={handleClick}>リロード</button>
    </>
  )
}

export default Page

 
動作確認すると、 select オプションがあるにも関わらず、再レンダリングが発生しています。

また、動画では分かりづらいかもしれませんが、 isRefetching の表示にも動きがあります。

 

notifyOnChangePropsを使い、dataの変更だけ再レンダリングする

useQuery のオプションを見たところ、 notifyOnChangeProps がありました。

If set, the component will only re-render if any of the listed properties change. If set to ['data', 'error'] for example, the component will only re-render when the data or error properties change. If set to "all", the component will opt-out of smart tracking and re-render whenever a query is updated. If set to a function, the function will be executed to compute the list of properties. By default, access to properties will be tracked, and the component will only re-render when one of the tracked properties change.

https://tanstack.com/query/v4/docs/react/reference/useQuery

 
そこで、今回 notifyOnChangePropsdata を追加し、dataに変更があるときのみ再レンダリングするよう修正します。

前との差分は、useQuery のオプションのみです。

const {data, refetch, isRefetching} = useQuery({
  queryKey: ['DataFilter'],
  queryFn: queryFn,
  select: (fishes) => {
    return fishes.length
  },
  notifyOnChangeProps: ['data']  // 追加
})

 
動作確認すると、 dataselect により再レンダリングされないことから、全体の再レンダリングも発生しなくなりました。

 

参考:v4系では、notifyOnChangePropsに tracked を設定できない

冒頭のBlogにあった tracked という指定は、現在はできなくなっています。
https://github.com/TanStack/query/releases/tag/v4.0.0-alpha.1

その代わり、参照していないプロパティは再レンダリングの対象外となっているようです。

 

参考資料:TanStack Queryのデータ変換やレンダリング最適化について

TanStack Queryの著者のBlogに詳細な記事がありました。

 
今回の記事でためしていないことなども書かれていて、とても参考になりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example/pull/3