Effective Query Keys in React Query
Tags: react, query, tutorial, webdev
Published: 2021-04-12 12:33:26

In [React Query](https://react-query.tanstack.com/overview), 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:

```javascript
const { data: todos } = useQuery(['todos'], () => fetchTodos());
```

In this post, we will have a look at:

1. The basic requirements a query key *must* fulfill.
2. How to *invalidate* the cache based on a (partial) query key.
3. My *personal flavor* of writing query keys; a few rules of thumb I have used in the past.
4. 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:

```javascript
useQuery(['todos'], () => fetchTodos());
useQuery(['users'], () => fetchUsers());
```

### It should contain all variables the query function depends on

There are two reasons why:

1. 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]`.
2. `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.

```javascript
// 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:

```javascript
useQuery(['todos', { format, dueToday }], /* ... */);
useQuery(['todos', { dueToday, format }], /* ... */);
```

The following two query keys are not:

```javascript
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:

```javascript
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:

```javascript
// only invalidate ['todos', 1]
queryClient.invalidateQueries(['todos', 1]);
```

Or, you can invalidate both by using the prefix `'todos'`:

```javascript
// 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:

```javascript
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:

```javascript
// 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:

```javascript
const QueryKeys = {
  todos: (todoId) => ['todos', todoId]
};

// ...

useQuery(QueryKeys.todos(todoId), /* ... */);
queryClient.invalidateQueries(QueryKeys.todos(1));
```

(Shoutout to [Tanner Linsley](https://twitter.com/tannerlinsley) for [also recommending this](https://twitter.com/tannerlinsley/status/1347951311518486529?s=20). As [@TkDodo](https://twitter.com/TkDodo) has pointed out to me, having a single file for this might lead to some [unfortunate copy-paste bugs](https://twitter.com/TkDodo/status/1347990364477681666?s=20). 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](https://github.com/tannerlinsley/react-query/blob/master/src/core/utils.ts#L255) in React Query:

```javascript
/**
 * 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 the `QueryClient`.
- 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 a `todoId`) to least descriptive (e.g., a `format` or `available` 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.