DEV Community

Cover image for Effective Query Keys in React Query
Sebastian Ruhleder
Sebastian Ruhleder

Posted on • Originally published at log.seruco.io

Effective Query Keys in React Query

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());
Enter fullscreen mode Exit fullscreen mode

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:

useQuery(['todos'], () => fetchTodos());
useQuery(['users'], () => fetchUsers());
Enter fullscreen mode Exit fullscreen mode

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.

// ok
useQuery('todos', /* ... */);
useQuery(['todos', todoId], /* ... */);
useQuery(['todos', todoId, { date }], /* ... */);

// not ok!
useQuery([function () {}], /* ... */);
Enter fullscreen mode Exit fullscreen mode

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 }], /* ... */);
Enter fullscreen mode Exit fullscreen mode

The following two query keys are not:

useQuery(['todos', todoId], /* ... */);
useQuery([todoId, 'todos'], /* ... */);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 }]
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

(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
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)