TanStack Queryの useQuery
や invalidateQueries
を使うときは 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!
とありました。
また、合わせて次のBlogを読むことで Query Key
についての理解が深まりました。
Effective React Query Keys | TkDodo's blog
それでも
- 一意なQuery Keyの定義について
invalidateQueries
を想定通りに動かすためのQuery Keyについて
などが気になり、実際に試してみたときのメモを残します。
目次
- 環境
- 準備:共通で使うコンポーネント
- Query Key の一致/不一致によるInvalidate Queriesの挙動
- Query Keyに動的な値を含めたいときの実装
- lukemorales/query-key-factoryで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 KeyinvalidateQueries
のexact
にそのまま渡すための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:
これにより、 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を管理する- 機能ごとにQuery Keyを管理した上で、
mergeQueryKeys
でまとめる
ができそうでしたので、それぞれためしてみます。
createQueryKeyStoreで、Query Keyを1箇所にまとめる
まずは createQueryKeyStore
でQuery Keyの定義ファイルを用意します。
以下の例では all
と first
を指定しています。
なお、 all
は「全件取得したいが queryFn
は使う側で指定したい」ため、READMEに従い null
を指定しました。
一方、 first
については、絞り込みを useQuery
の select
で行いたいものの、 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