TanStack Queryの Query Key について調べてみた

TanStack Queryの useQueryinvalidateQueries を使うときは Query Key を指定します。

Query Keyとはなにかを公式ドキュメントで見たところ

At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it!

https://tanstack.com/query/v4/docs/react/guides/query-keys

とありました。

また、合わせて次のBlogを読むことで Query Key についての理解が深まりました。
Effective React Query Keys | TkDodo's blog

それでも

  • 一意なQuery Keyの定義について
  • invalidateQueries を想定通りに動かすためのQuery Keyについて

などが気になり、実際に試してみたときのメモを残します。

 
目次

 

環境

  • React 18.2.0
  • TanStack Query 4.32.6

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

 

準備:共通で使うコンポーネント

今回、いくつかのサンプルで共通的に使うコンポーネントがあるため、事前に用意しておきます。

 

TanStack Queryを使っているコンポーネント

まず、TanStack Queryの useQuery により、APIからのデータ取得とその表示を行うコンポーネントを用意します。今回は Fishes と名付けました。

また、useQuery で使う queryKey については、色々なQuery Keyを検証できるよう、コンポーネントのpropsに定義します。

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

type Props = {
  queryKey: any[]
}

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

export const Fishes = ({queryKey}: Props) => {
  const {data} = useQuery({
    queryKey: queryKey,
    queryFn: queryFn
  })

  return (
    <>
      {data && (
        <ul>
          {data.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
        </ul>
      )}
    </>
  )
}

 

ボタンをクリックしたら invalidateQueries を実行するコンポーネント

今回、Query Keyが重複する/しないときの invalidateQueries の結果を見てみたいため、ボタンをクリックしたら invalidateQueries を実行するコンポーネントを用意します。

また、こちらでも色々なパターンを試せるよう、コンポーネントのpropsとして、

  • invalidateQueries を実行するときのQuery Key
  • invalidateQueriesexact にそのまま渡すためのboolean

を定義します。

なお、invalidateするときのQueryKeyは色々試すため、今回は any[] 型としています。

import {useQueryClient} from "@tanstack/react-query";

type Props = {
  queryKeyForReload: any[]
  exact?: boolean
}

export const ReloadButton = ({queryKeyForReload, exact = false}: Props) => {
  const queryClient = useQueryClient()

  const handleClick = () => {
    queryClient.invalidateQueries({queryKey: queryKeyForReload, exact: exact})
  }

  return (
    <button type={'button'} onClick={handleClick}>更新</button>
  )
}

 

Query Key の一致/不一致によるInvalidate Queriesの挙動

Query Keyは

unique to the query's data

の用途で使われます。

そこで、どうすれば同一とされるか知りたくなりました。

 
公式ドキュメントでは、TanStack Queryの Query Key のフォーマットは

  • Generic List/Index resources
  • Non-hierarchical resources

https://tanstack.com/query/v4/docs/react/guides/query-keys#simple-query-keys

とありました。

そこで、まずは長さ1のArrayで構成されるQuery Keyの挙動からみていきます。

 

長さ1のArrayで構成されるQuery Keyの場合

useQuery間でQuery Keyが異なるときのinvalidateQueries

今回、複数の Fishes コンポーネントに対してそれぞれ別の

  • ['first_key']
  • ['second_key']

というQuery Keyを渡すことで、各 useQuery が実行されるようにします。

また、invalidateQueries で片方だけ更新されるよう、 ReloadButton['first_key'] を渡します。

import {Fishes} from "../../../../components/query_key/Fishes";
import {ReloadButton} from "../../../../components/query_key/ReloadButton";

const Page = () => {
  const FIRST_QUERY_KEY = ['first_key']
  const SECOND_QUERY_KEY = ['second_key']

  return (
    <>
      <h1>異なる文字列のQueryKeyで ['first_key'] にてinvalidate</h1>

      <h2>queryKey = ['first_key']</h2>
      <Fishes queryKey={FIRST_QUERY_KEY} />

      <h2>queryKey = ['second_key']</h2>
      <Fishes queryKey={SECOND_QUERY_KEY} />

      <hr />

      <ReloadButton queryKeyForReload={FIRST_QUERY_KEY} />
    </>
  )
}

export default Page

 
結果は以下です。

 
ボタンのクリック前

それぞれ別のデータが表示されています。

 
ボタンのクリック後

Query Keyとして ['first_key'] を渡したコンポーネントだけ更新されました。

 

useQuery間でQuery Keyが同一のときのinvalidateQueries

続いて、同じQuery Keyを指定してみます。

const Page = () => {
  const QUERY_KEY = ['same_key']

  return (
    <>
      <h1>同じ文字列のQueryKey</h1>

      <h2>queryKey = ['same_key'] その1</h2>
      <Fishes queryKey={QUERY_KEY} />

      <h2>queryKey = ['same_key'] その2</h2>
      <Fishes queryKey={QUERY_KEY} />

      <hr />

      <ReloadButton queryKeyForReload={QUERY_KEY} />
    </>
  )
}

 
結果は以下です。

 
ボタンのクリック前

同じデータが表示されています。

 
ボタンのクリック後

データは更新されたものの、両方とも同じデータになりました。

 

複数の値で構成されるQuery Keyの場合

公式ドキュメントには以下の記載があり、複数の値で構成されるQuery Keyについても書かれています。

When a query needs more information to uniquely describe its data, you can use an array with a string and any number of serializable objects to describe it. This is useful for:

  • Hierarchical or nested resources
    • It's common to pass an ID, index, or other primitive to uniquely identify the item
  • Queries with additional parameters
    • It's common to pass an object of additional options

https://tanstack.com/query/v4/docs/react/guides/query-keys#array-keys-with-variables

 
最初の要素は文字列で、次の要素は

  • Arrayの要素
  • オブジェクト

にできるようです。

 
そこで、それぞれのパターンの Query Key を見ていくことにします。

 

Arrayの要素の場合

Query Keyの最初の要素のみをinvalidateQueriesに渡したとき

Query Keyの最初の要素、つまり文字列部分だけ渡してみます(ここでは key)。

import {Fishes} from "../../../../../components/query_key/Fishes";
import {ReloadButton} from "../../../../../components/query_key/ReloadButton";

const Page = () => {
  return (
    <>
      <h1>リロードするキーとして ['key'] を指定</h1>

      <h2>queryKey = ['key', 1]</h2>
      <Fishes queryKey={['key', 1]} />

      <h2>queryKey = ['key', 2]</h2>
      <Fishes queryKey={['key', 2]} />

      <hr />

      <ReloadButton queryKeyForReload={['key']} />
    </>
  )
}

export default Page

 
結果は以下です。

 
ボタンのクリック前

それぞれ別のデータが表示されています。

 
ボタンのクリック後

両方とも更新対象とみなされ、データが更新されました。

すべてのQuery KeyをinvalidateQueriesに渡したとき

続いて、リロードしたいQuery Keyの全部 ['key', 1] を指定してみます。

import {Fishes} from "../../../../../components/query_key/Fishes";
import {ReloadButton} from "../../../../../components/query_key/ReloadButton";

const KEY_FOR_RELOAD = ['key', 1]

const Page = () => {
  return (
    <>
      <h1>リロードするキー ['key', 1] をすべて指定</h1>

      <h2>queryKey = ['key', 1]</h2>
      <Fishes queryKey={KEY_FOR_RELOAD} />

      <h2>queryKey = ['key', 1]</h2>
      <Fishes queryKey={['key', 2]} />

      <hr />

      <ReloadButton queryKeyForReload={KEY_FOR_RELOAD} />
    </>
  )
}

export default Page

 
結果は以下です。

 
ボタンのクリック前

前と同様、両方とも違うデータが表示されています。

 
ボタンのクリック後

キーに一致している上の方だけ更新され、下はそのままになっています。

 

最初のQuery Keyのみ + exactをinvalidateQueriesに渡したとき

ここで、あらためて公式ドキュメントを見ると、invalidateQueries には exact というオプションがありました。

The invalidateQueries API is very flexible, so even if you want to only invalidate todos queries that don't have any more variables or subkeys, you can pass an exact: true option to the invalidateQueries method:

https://tanstack.com/query/v3/docs/react/guides/query-invalidation#query-matching-with-invalidatequeries

 
これにより、 Query Key の複数要素を指定したとしても、うっかり一致してしまうことを避けられそうです。

そこで、 ['key', 1]['key', 2]['key'] の3つのQuery Key を各コンポーネントに設定した上で、更新対象は key + exact: true としてためしてみます。

import {Fishes} from "../../../../../components/query_key/Fishes";
import {ReloadButton} from "../../../../../components/query_key/ReloadButton";

const Page = () => {
  return (
    <>
      <h1>リロードするキーは ['key'] だが、exact=true</h1>

      <h2>queryKey = ['key', 1]</h2>
      <Fishes queryKey={['key', 1]} />

      <h2>queryKey = ['key', 2]</h2>
      <Fishes queryKey={['key', 2]} />

      <h2>queryKey = ['key']</h2>
      <Fishes queryKey={['key']} />

      <hr />

      <ReloadButton queryKeyForReload={['key']} exact={true} />
    </>
  )
}

export default Page

 
結果は以下です。

 
ボタンのクリック前

それぞれ別のデータが表示されています。

 
ボタンのクリック後

Query Keyに ['key'] を持つコンポーネントのみ、更新されました。

 

オブジェクトの場合

Query Keyにはオブジェクトも渡せるため、

  • invalidateQueriesのqueryKeyと同じ値・定義順のオブジェクト
  • invalidateQueriesのqueryKeyと同じ値だが定義が異なるオブジェクト
  • invalidateQueriesのqueryKeyに加え、undefined値の属性を持つオブジェクト
  • invalidateQueriesのqueryKeyに加え、null値の属性を持つオブジェクト

の各パターンをためしてみます。

import {Fishes} from "../../../../components/query_key/Fishes";
import {ReloadButton} from "../../../../components/query_key/ReloadButton";

const foo = 1
const bar = 'hoge'
const baz = [3, 4]

const Page = () => {
  const sameValues = {foo, bar, baz}
  const diffOrder = {bar, baz, foo}
  const undefinedValue = {foo, bar, baz, other: undefined}
  const nullValue = {foo, bar, baz, other: null}
  const keyObject = {foo, bar, baz}

  return (
    <>
      <h1>2キーはオブジェクト</h1>

      <h2>invalidateQueriesのqueryKeyと同じ値・定義順のオブジェクト</h2>
      <Fishes queryKey={['key', sameValues]} />

      <h2>invalidateQueriesのqueryKeyと同じ値だが定義が異なるオブジェクト</h2>
      <Fishes queryKey={['key', diffOrder]} />

      <h2>invalidateQueriesのqueryKeyに加え、undefined値の属性を持つオブジェクト</h2>
      <Fishes queryKey={['key', undefinedValue]} />

      <h2>invalidateQueriesのqueryKeyに加え、null値の属性を持つオブジェクト</h2>
      <Fishes queryKey={['key', nullValue]} />

      <hr />

      <ReloadButton queryKeyForReload={['key', keyObject]} />
    </>
  )
}

export default Page

 
結果は以下です。

 
ボタンのクリック前

パターンのうち

  • invalidateQueriesのqueryKeyと同じ値・定義順のオブジェクト
  • invalidateQueriesのqueryKeyと同じ値だが定義が異なるオブジェクト
  • invalidateQueriesのqueryKeyに加え、undefined値の属性を持つオブジェクト

は同じとみなされて全て同じデータが表示される一方、

  • invalidateQueriesのqueryKeyに加え、null値の属性を持つオブジェクト

は別とみなされ、別のデータがロードされていました。

 
ボタンのクリック後

invalidateQueriesのqueryKeyと同じ属性を全部のオブジェクトが持っていたため、すべてリロードされました。

 
ここまでで、Query Keyが同一かどうかについては、気になっていた点をためせました。

 

Query Keyに動的な値を含めたいときの実装

Query Key に関するTanStack QueryのAuthorのBlogを読んでいたところ、

I have a query, it fetches some data. Now I click this button and I want to refetch, but with different parameters

The answer is: You don't.

That's not what refetch is for - it's for refetching with the same parameters.

If you have some state that changes your data, all you need to do is to put it in the Query Key, because React Query will trigger a refetch automatically whenever the key changes. So when you want to apply your filters, just change your client state:

https://tkdodo.eu/blog/effective-react-query-keys#automatic-refetching

とありました。

 
過去に refetch へ動的な値を渡したくなった時があったため、今回ためしてみます。

以下の例では、ボタンをクリックするごとに、useStateで持っている colorId が切り替わり、その結果画面の表示も切り替わる想定です。

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

const queryFn = (colorId: number) =>  new DefaultApi().fetchColor(colorId, {headers: {Prefer: `example=case${colorId}`}}).then(r => r.data)

const Page = () => {
  const [colorId, setColorId] = useState(1)
  const {data} = useQuery({
    queryKey: ['Color', colorId],
    queryFn: () => queryFn(colorId)
  })

  const handleColorId1 = () => {
    setColorId(1)
  }

  const handleColorId2 = () => {
    setColorId(2)
  }

  if (!data) return

  return (
    <>
      <h1>動的に変わるQueryKey</h1>

      {data && (
        <p>ID: {data.id} / Name: {data.colorName}</p>
      )}

      <hr />

      <button onClick={handleColorId1}>colorIdを1にする</button>
      <button onClick={handleColorId2}>colorIdを2にする</button>
    </>
  )
}

export default Page

 
結果は以下です。

 
初期表示

 
右ボタンをクリック

データが切り替わりました。

 
左ボタンをクリック

さらにデータが切り替わりました。

 
想定通りの結果となり、 refetch に動的な値を渡せなくても Query Keyでの制御ができました。

 

lukemorales/query-key-factoryでQuery Keyを管理する

引き続きTanStack QueryのAuthorのBlogを読んだところ、Query Key factoriesについての記載がありました。

In the examples above, you can see that I've been manually declaring the Query Keys a lot. This is not only error-prone, but it also makes changes harder in the future, for example, if you find out that you'd like to add another level of granularity to your keys.

That's why I recommend one Query Key factory per feature. It's just a simple object with entries and functions that will produce query keys, which you can then use in your custom hooks. For the above example structure, it would look something like this:

https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories

 
また、Tansktack Queryの公式ドキュメントでは lukemorales/query-key-factory を使ってQuery Keyを管理する方法が記載されていました。
Query Key Factory | TanStack Query Docs

 
lukemorales/query-key-factory のREADMEによると、

ができそうでしたので、それぞれためしてみます。

 

createQueryKeyStoreで、Query Keyを1箇所にまとめる

まずは createQueryKeyStore でQuery Keyの定義ファイルを用意します。

以下の例では allfirst を指定しています。

なお、 all は「全件取得したいが queryFn は使う側で指定したい」ため、READMEに従い null を指定しました。

一方、 first については、絞り込みを useQueryselect で行いたいものの、 createQueryKeyStore の引数としては渡せませんでした。そこで、定義ファイルでは指定せず、使う側で指定するようにしています。

import {createQueryKeyStore} from "@lukemorales/query-key-factory";
import {DefaultApi} from "../../../../../types";

export const queries = createQueryKeyStore({
  fishes: {
    all: null, // 全件取得。queryFnは使う側で定義
    first: () => ({  // 最初の1件取得。selectによる絞り込みは `createQueryKeyStore` ではできないため、使う側で行う
      queryKey: ['first'],
      queryFn: () => new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then(r => r.data.fishes),
    })
  }
})

 
続いて、使う側のコンポーネントです。

...queries のようにスプレッド構文を使い、 useQuery の引数に渡しています。

なお、スプレッド構文を使う際、

  • fishes.all の場合は、定義側で null 指定していたためか、 () 無し
  • fishes.first の場合は、 () あり

という違いがありました。

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

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

const Page = () => {
  const {data: allData} = useQuery({
    ...queries.fishes.all,
    queryFn: queryFn
  })


  const {data: firstData} = useQuery({
    ...queries.fishes.first(),
    select: (fishes) => fishes[0]
  })

  if (!allData || !firstData) return

  return (
    <>
      <h1>Query Factory</h1>

      <h2>fishes.allの実行</h2>
      <ul>
        {allData.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
      </ul>

      <hr />

      <h2>fishes.firstの実行</h2>
      <ul>
        ID: {firstData.id} / Name: {firstData.name}
      </ul>
    </>
  )
}

export default Page

 
動かしてみると、以下のような初期表示になり、 Query Keyが正しく設定されているようでした。

なお、画面を何度もリロードしても、 fishes.first の方は1件しか表示されません。

 

createQueryKeys + mergeQueryKeysで機能ごとのQuery Keyをまとめる

機能ごとに Query Key を用意した上でまとめる場合は、

  • createQueryKeys で、機能ごとのキーを定義する
  • mergeQueryKeys で、機能ごとのキーをまとめる

とすれば良さそうでした。

 
まずは機能ごとのキーを用意します。

なお、「queryKeyは属性の値(ここでは all)にして、 queryFn も指定したい」場合は、 queryKey[''] とArrayに空文字だけの要素を指定すれば良さそうです。READMEに記載はなかったものの、以下のissueに記載がありました。
https://github.com/lukemorales/query-key-factory/issues/74

 
fishQueries

import {createQueryKeys} from "@lukemorales/query-key-factory";
import {DefaultApi} from "../../../../../types";

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

export const fishQueries = createQueryKeys('fish', {
  all: {
      queryKey: [''],
      queryFn: fishQueryFn
  }
})

 
appleQueries

import {createQueryKeys} from "@lukemorales/query-key-factory";
import {DefaultApi} from "../../../../../types";

const appleQueryFn = () => new DefaultApi().fetchApples().then(r => r.data.apples)

export const appleQueries = createQueryKeys('apple',{
  all: {
    queryKey: [''],
    queryFn: appleQueryFn
  }
})

 
続いて、 mergeQueryKeys するファイルを用意します。

import {mergeQueryKeys} from "@lukemorales/query-key-factory";
import {fishQueries} from "./fishQueries";
import {appleQueries} from "./appleQueries";

export const mergedQueries = mergeQueryKeys(appleQueries, fishQueries)

 
最後に、コンポーネントでマージしたQuery Keyを使います。

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

const Page = () => {
  const {data: fishData} = useQuery({...mergedQueries.fish.all})
  const {data: appleData} = useQuery({...mergedQueries.apple.all})

  if (!fishData || !appleData) return

  return (
    <>
      <h1>QueryKey Factoryで生成した複数のキーを表示</h1>

      <h2>Fishes.allの実行</h2>
      <ul>
        {fishData.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
      </ul>

      <hr />

      <h2>Apples.allの実行</h2>
      <ul>
        {appleData.map(a => <li key={a.id}>ID: {a.id} / Name: {a.name}</li>)}
      </ul>
    </>
  )
}

export default Page

 
動かしてみるとそれぞれのデータが表示されました。マージされたQuery Keyを使って動作しているようです。

 

lukemorales/query-key-factoryでのQuery Key重複

最後に、 lukemorales/query-key-factory を使ったときにQuery Keyの重複が発生したときの挙動を見てみます。

なお、 createQueryKeyStore を使う場合は発生しないと考えています。createQueryKeyStore の引数はオブジェクトなことから、Query Keyの重複は属性の重複となり、その結果エラーになるためです。

 
なお、ここで使う createQueryKeys の定義は以下の通りです。 Query Key items.all が重複しています。

appleQueries

import {createQueryKeys} from "@lukemorales/query-key-factory";
import {DefaultApi} from "../../../../../types";

const appleQueryFn = () => new DefaultApi().fetchApples().then(r => r.data.apples)

export const appleQueries = createQueryKeys('items',{
  all: {
    queryKey: [''],
    queryFn: appleQueryFn
  }
})

 

fishQueries

import {createQueryKeys} from "@lukemorales/query-key-factory";
import {DefaultApi} from "../../../../../types";

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

export const fishQueries = createQueryKeys('items', {
  all: {
    queryKey: [''],
    queryFn: fishQueryFn
  }
})

 

createQueryKeysの結果をそれぞれimportしたときに重複する場合

マージせずに直接importした場合です。

import {useQuery} from "@tanstack/react-query";
import {appleQueries} from "../appleQueries";
import {fishQueries} from "../fishQueries";

const Page = () => {
  const {data: fishData} = useQuery({
    ...fishQueries.all,
  })

  const {data: appleData} = useQuery({
    ...appleQueries.all,
  })

  if (!fishData || !appleData) return

  return (
    <>
      <h1>Query Factoryで同じキーを生成し、それぞれimportした場合</h1>
      <h2>Fishes.allの実行</h2>
      <ul>
        {fishData.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
      </ul>

      <hr />

      <h2>Apples.allの実行</h2>
      <ul>
        {appleData.map(a => <li key={a.id}>ID: {a.id} / Name: {a.name}</li>)}
      </ul>
    </>
  )
}

export default Page

 
動かしてみると、fishData のデータで表示されています。

 

createQueryKeys + mergeQueryKeysしたときにQuery Keyが重複する場合

続いて mergeQueryKeys したときに重複する場合です。

まずは mergeQueryKeys のファイルを使います。

import {mergeQueryKeys} from "@lukemorales/query-key-factory";
import {fishQueries} from "../fishQueries";
import {appleQueries} from "../appleQueries";

export const queries = mergeQueryKeys(fishQueries, appleQueries)

 
続いてコンポーネントです。

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

const Page = () => {
  const {data: fishData} = useQuery({
    ...queries.items.all
  })

  const {data: appleData} = useQuery({
    ...queries.items.all,
  })

  if (!fishData || !appleData) return

  return (
    <>
      <h1>Query Factoryで同じキーを生成し、mergeQueryKeysを使ってマージした場合</h1>
      <h2>Fishes.allの実行</h2>
      <ul>
        {fishData.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
      </ul>

      <hr />

      <h2>Apples.allの実行</h2>
      <ul>
        {appleData.map(a => <li key={a.id}>ID: {a.id} / Name: {a.name}</li>)}
      </ul>
    </>
  )
}

export default Page

 
動かしてみると、appleData のデータのみが表示されています。

mergeQueryKeys の引数として最後に指定した方の値が取得されているようです。

export const queries = mergeQueryKeys(fishQueries, appleQueries)

 

ソースコード

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

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