In React Query, every query uses a query key to identify the data it manages. For example, the following query uses the query key ['todos']
to identify a list of to-dos:
const { data: todos } = useQuery(['todos'], () => fetchTodos());
In this post, we will have a look at:
- The basic requirements a query key must fulfill.
- How to invalidate the cache based on a (partial) query key.
- My personal flavor of writing query keys; a few rules of thumb I have used in the past.
- How query keys work under the hood.
The Basics
There are some requirements a query key must fulfill:
It must uniquely identify the data managed by the query
React Query uses query keys for caching. Make sure to use query keys that uniquely identify the data you fetch from a server:
useQuery(['todos'], () => fetchTodos());
useQuery(['users'], () => fetchUsers());
It should contain all variables the query function depends on
There are two reasons why:
- The variable is necessary to identify the data since it is used to fetch it. The to-dos for two users, who are identified by a
userId
, can't both use['todos']
. A sensible query key would be['todos', userId]
. -
useQuery
calls the query function and thereby refetches the data whenever the query key changes. Including a variable in a query key is an easy way to automatically trigger a refetch and keep your data up-to-date.
It must be serializable
A query key can be a string or an array of strings, numbers, or even nested objects. However, it must be serializable: It cannot contain cyclic objects or functions.
// ok
useQuery('todos', /* ... */);
useQuery(['todos', todoId], /* ... */);
useQuery(['todos', todoId, { date }], /* ... */);
// not ok!
useQuery([function () {}], /* ... */);
Query keys are hashed deterministically, which means the order of the keys in an object does not matter (whereas the order of elements in an array does!). The following two query keys are identical:
useQuery(['todos', { format, dueToday }], /* ... */);
useQuery(['todos', { dueToday, format }], /* ... */);
The following two query keys are not:
useQuery(['todos', todoId], /* ... */);
useQuery([todoId, 'todos'], /* ... */);
Cache Invalidation
You can invalidate queries matching a partial or an exact query key by using the invalidateQueries
method of the QueryClient
. This method will mark the matched queries as stale and refetch them automatically if they are in use. Let's consider a simple example:
useQuery(['todos', todoId], () => fetchTodo(todoId));
Imagine this hook is used twice on your page: once for todoId = 1
and once for todoId = 2
. Your query cache will contain two query keys (and the data identified by them): ['todos', 1]
and ['todos', 2]
.
You can invalidate a specific to-do by using invalidateQueries
with an exact query key:
// only invalidate ['todos', 1]
queryClient.invalidateQueries(['todos', 1]);
Or, you can invalidate both by using the prefix 'todos'
:
// invalidate both ['todos', 1] and ['todos', 2]
queryClient.invalidateQueries(['todos']);
// you can even omit the array around the 'todos' label
// to achieve the same result
queryClient.invalidateQueries('todos');
Since cache invalidation allows you to use partial query keys to invalidate multiple queries at once, the way you structure your query keys has significant implications on how effectively you can manage data throughout your application.
The Flavor
I've established a set of best practices for myself when defining query keys. This list is by no means comprehensive, and you will find your own rhythm for dealing with query keys. But they might give you a solid foundation.
Go from most descriptive to least descriptive
You should start every query key with a label that identifies the type of data the query manages. For example, if the data describes a to-do (or a list of to-dos), you should start with a label like 'todos'
. Since partial query matching is prefix-based, this allows you to invalidate cohesive data easily.
Then, you should sort the variables within the query key from most descriptive (e.g., a todoId
, which directly describes a concrete to-do) to least descriptive (e.g., a format
). Again, this allows us to make full use of the prefix-based cache invalidation.
Violating this best practice might lead to this:
useQuery(['todos', { format }, todoId], /* ... */);
// how do we invalidate a specific todoId irrespective of
// its format?
queryClient.invalidateQueries(['todos', /* ??? */, todoId]);
Bundle query parameters within an object
Often, I use path and query parameters of the data's URI to guide the query key's layout. Everything on the path gets its own value within the query key, and every attribute-value pair of the query component of a resource is bundled within an object at the end. For example:
// path and query parameters
'/resources/{resourceId}/items/{itemId}?format=XML&available'
// query key
['resources', resourceId, itemId, { format, available }]
Use functions to create query keys
If you reuse a query key, you should define a function that encapsulates its layout and labels. Typos are notoriously hard to debug when invalidating or removing queries, and it's easy to accidentally write ['todo']
instead of ['todos']
. For this reason, introduce a central place where you generate your query keys:
const QueryKeys = {
todos: (todoId) => ['todos', todoId]
};
// ...
useQuery(QueryKeys.todos(todoId), /* ... */);
queryClient.invalidateQueries(QueryKeys.todos(1));
(Shoutout to Tanner Linsley for also recommending this. As @TkDodo has pointed out to me, having a single file for this might lead to some unfortunate copy-paste bugs. The emphasis here is on using functions to generate query keys, not on having only one file.)
Under the Hood
Reading about rules and best practices is one thing. Understanding why they apply (or should be applied) is another. Let's have a look at how query keys are hashed in React Query:
/**
* Default query keys hash function.
*/
export function hashQueryKey(queryKey: QueryKey): string {
const asArray = Array.isArray(queryKey) ? queryKey : [queryKey]
return stableValueHash(asArray)
}
/**
* Hashes the value into a stable hash.
*/
export function stableValueHash(value: any): string {
return JSON.stringify(value, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val
)
}
First, if the query key is a string, it will be wrapped within an array. That means, 'todos'
and ['todos']
are essentially the same query key. Second, the hash of a query key is generated by using JSON.stringify
.
To achieve a stable hash, the stableValueHash
function makes use of the replacer
parameter of JSON.stringify
. This function is called for every value or key-value pair within the value
parameter that needs to be "stringified." In case the value is an object, its keys are sorted. This is the reason why the order of the keys within an object does not matter!
In most cases, you won't need to consult this code when writing query keys. In fact, if you do, your query keys might be too complex. However, looking under the hood of libraries we use every day is an excellent way to engage with them on a deeper level and provides the occasional Aha! moment.
Summary
Query keys:
- must uniquely identify the data they describe,
- should contain all variables the query function depends on, and
- must be serializable.
Cache invalidation:
- You can invalidate the query cache with the
invalidateQueries
function of theQueryClient
. - You can use a partial query key or an exact query key to invalidate the cache. Partial query matching is prefix-based.
Best practices:
- Go from most descriptive (e.g., a fixed label like
'todos'
and atodoId
) to least descriptive (e.g., aformat
oravailable
flag). - Bundle query parameters within an object and use the path of your resource to guide the query key's layout.
- Write functions to generate query keys consistently.
Under the hood:
- String query keys are wrapped in an array.
'todos'
and['todos']
are identical query keys. - Query keys are hashed (and compared) via their
JSON.stringify
serialization. Keys in objects are sorted.
Top comments (0)