Skip to content

How queryKey Comparison Works

13 min read

In this post, I want to talk about how TanStack Query decides that two queryKeys are the same key.

This is for TanStack Query users who have wondered why a queryKey, which is created as a new array on every render, does not cause a cache miss every time, and whether object key order or undefined values affect the cache, and by the end you will know the rule that makes two keys equal and how that rule affects the cache. To give the conclusion first, TanStack Query uses the string produced by serializing the queryKey with hashKey as the cache key, and in that process object key order is ignored while array element order is preserved.

A queryKey is the array TanStack Query uses as the basis for managing the query cache. The same key means the same data, and when the key ['user', userId] changes because userId changed, a cache miss occurs and the data is fetched again.

Inside QueryCache

According to TkDodo's Inside React Query, QueryCache is ultimately just an in-memory data structure. In the v5 official implementation, that data structure is not a plain object but a Map<string, Query>. The field is typed as QueryStore, and the constructor assigns it a new Map<string, Query>(). Entries are stored and looked up with queryHash as the key. The key is the serialized form of the queryKey (queryHash), and the value is an instance of the Query class. The code and output in this post are based on @tanstack/query-core 5.104.1.

Unlike the plain object that older versions used, a Map never collides with keys inherited from a prototype.

What happens each time useQuery is called is straightforward. The queryKey is converted into a hash, and that hash is used to look it up in the Map. If an entry exists, TanStack Query retrieves the cached Query instance. Otherwise, it creates a new one and calls set.

This naturally leads to another question: why serialize the queryKey into a string at all? Why not use the array itself as the key, as in Map<QueryKey, Query>?

The answer lies in JavaScript's equality model. A native Map compares keys using reference equality. Even when their contents are identical, objects at different locations in memory are treated as different keys.

const m = new Map();
m.set(['user', 1], 'alice');
m.get(['user', 1]); // undefined. 새로 만든 배열은 다른 참조다

But in a React component, useQuery({ queryKey: ['user', userId] }) creates a new array instance on every render. The queryKey arrays from the first and second renders are separate objects in memory even if their contents match. If the cache depended on reference equality, a component displaying the same data would miss the cache on every render.

The solution to the problem caused by reference equality is simple: convert reference equality into structural equality. Create a deterministic string using only the contents of the queryKey, then use that string as the Map key. This restores the semantics we want: "equal contents mean the same key." JSON.stringify is simply the most straightforward tool for that conversion.

Key Sorting in hashKey

The function that produces the hash is hashKey. Its official implementation in packages/query-core/src/utils.ts looks like this.

export function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =>
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) => {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}

It uses JSON.stringify with a replacer callback that sorts the keys of plain objects lexicographically before serialization. Strictly speaking, this is UTF-16 code unit order, the default comparison of sort(), so uppercase keys come before lowercase ones.

This sorting is fundamental because string serialization carries an additional, stronger requirement: semantically equivalent inputs must always produce the same string. Ordinary JSON.stringify, however, preserves key order. { a: 1, b: 2 } and { b: 2, a: 1 } are semantically equivalent objects, but they serialize into different strings and therefore occupy different cache slots. That would bring back duplicate requests for the same data.

The technique that consistently prevents this is a canonical form. It forces semantically equivalent inputs to map to exactly one representation. This is why the hashKey replacer sorts the keys of plain objects. By producing the same output regardless of input order, it makes semantically equivalent objects always become the same string. The reverse is not guaranteed, as we will see later.

The fact that arrays are not sorted is the other side of the same principle. An array is a data structure in which order itself carries meaning, so sorting it would destroy information. Object key order is incidental; array element order is intentional. hashKey treats the two accordingly. Maintainer TkDodo's advice in Effective React Query Keys to structure a queryKey from the most generic to the most specific also follows from array order carrying meaning. The reason he gives is invalidation: keys that share the same leading part can all be invalidated at once with ['todos']. That comparison is handled not by the hash but by prefix matching, which we will see later.

Key sorting applies only to plain objects. In the same file, isPlainObject checks whether Object.prototype.toString returns [object Object] and whether the prototype is Object.prototype (or null) to distinguish plain object literals from class instances. As a result, a literal such as { foo: 1 } is sorted, while an instance created with class User { ... } passes through unsorted. If you put a class instance directly into a queryKey, its keys are not sorted, so it is serialized in the order its fields were assigned, and equal values can still produce different hashes.

Seen from the calling side, there are two results.

Object key order does not matter

useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn });
useQuery({ queryKey: ['todos', { page: 1, status: 'done' }], queryFn });
// 두 쿼리는 같은 캐시 슬롯을 공유한다

Without key sorting, you would have to remember the key order every time you used an object literal.

Array element order matters

useQuery({ queryKey: ['todos', status, page], queryFn });
useQuery({ queryKey: ['todos', page, status], queryFn });
// 두 쿼리는 다른 캐시이다

Values That Serialization Changes

It is also useful to know that undefined values disappear during serialization. { a: 1, b: undefined } and { a: 1 } produce the same hash. (I once made the mistake of thinking, "I explicitly included undefined, so this must be a different cache!")

Inside an array, it behaves differently. An undefined array element does not disappear; it becomes null. So ['user', undefined] and ['user', null] are the same key, and both differ from ['user']. This is the case with ['user', userId] when userId is still undefined. Even if you block the fetch with enabled: false, a ["user",null] slot is still created in the cache. I confirmed this by creating the object that useQuery uses internally, a QueryObserver, with the same options.

undefined is not the only such value. Because hashKey is built on top of JSON.stringify, most values that JSON cannot represent are turned into other values without any error. I ran the code below on 2026-10-08 with @tanstack/query-core 5.104.1 and Node v24.16.0, and the comments are the actual output.

import { hashKey, QueryClient } from '@tanstack/query-core'
 
console.log(hashKey(['user', undefined])) // ["user",null]
console.log(hashKey(['user', null])) // ["user",null]
console.log(hashKey(['user'])) // ["user"]
console.log(hashKey(['f', { cb: () => 1 }])) // ["f",{}]
console.log(hashKey(['m', new Map([['a', 1]])])) // ["m",{}]
console.log(hashKey(['d', new Date('2025-12-30T00:00:00Z')])) // ["d","2025-12-30T00:00:00.000Z"]
 
const queryClient = new QueryClient()
queryClient.setQueryData(['m', new Map([['a', 1]])], 'mapA')
console.log(queryClient.getQueryData(['m', new Map([['b', 2]])])) // mapA

Running other values the same way gives the following summary.

Value in the key Serialized result Treated as the same key as
undefined, NaN, Infinity, or a function as an array element null a key with null in that position
undefined or a function as an object property the property disappears a key without that property
Map, Set {} every Map, Set, and empty object, regardless of contents
Date an ISO string the same ISO string
BigInt throws TypeError none
circular reference throws RangeError if the cycle runs only through plain objects, TypeError if it passes through an array or class instance none

The most dangerous ones are Map and Set. In the code above, data stored under new Map([['a', 1]]) came back when looked up with new Map([['b', 2]]). Since there is no error, there is also no clue that the screen is rendering the wrong data.

Only two cases surface as errors: BigInt and circular references. For circular references, the error depends on what forms the cycle. A cycle that runs only through plain objects ends in RangeError: Maximum call stack size exceeded, while a cycle that passes through even one array or class instance, as in arr.push(arr), ends in TypeError: Converting circular structure to JSON. Because the replacer returns a new object for every plain object, the cycle detection in JSON.stringify never sees the same object twice, whereas the replacer returns arrays and class instances as is, so the cycle is caught. Date, on the other hand, becomes an ISO string through toJSON, so for cache lookup the same instant produces the same key, which makes it relatively safe.

I once placed a Date directly in a key and spent a long time wondering, "Why is the cache refreshing even though it is the same instant?" A Date that points to the same instant becomes the same ISO string even if it is a different instance, so it produces the same hash. If a different key came out every time, the time itself was different, even if it looked like the same instant. Creating a new Date() during render puts a time that differs by milliseconds into every render, and each one becomes a new key.

So it is safest to put only strings, numbers, booleans, null, and arrays and plain objects made of them into a queryKey.

queryKeyHashFn

There is an escape hatch from this constraint. Through the queryKeyHashFn option, TanStack Query lets you replace the hash function itself. Internally, hashQueryKeyByOptions(queryKey, options) branches: if queryKeyHashFn exists in the options, it calls that; otherwise, it calls the default hashKey.

Replacing it means substituting hashKey entirely. The key sorting described earlier goes away with it, so if you need sorting you have to implement it yourself. The case where this option is useful is a value that the default serialization throws on, such as BigInt. I ran the code below in the same environment.

import { QueryClient } from '@tanstack/query-core'
 
const bigintSafeHash = (queryKey) =>
  JSON.stringify(queryKey, (_, v) => (typeof v === 'bigint' ? v.toString() : v))
 
const queryClient = new QueryClient({
  defaultOptions: { queries: { queryKeyHashFn: bigintSafeHash } },
})
 
queryClient.setQueryData(['order', 9007199254740993n], 'ok')
console.log(queryClient.getQueryData(['order', 9007199254740993n])) // ok
console.log(queryClient.getQueryData(['order', '9007199254740993'])) // ok
 
queryClient.setQueryData(['todos', { status: 'done', page: 1 }], 'A')
console.log(queryClient.getQueryData(['todos', { page: 1, status: 'done' }])) // undefined

The second output means that a BigInt and a string holding the same number become the same key. The last output is the result of losing key sorting: objects that differ only in key order are no longer treated as the same key.

Where you register it also changes the result. If you register it through the QueryClient defaultOptions or setQueryDefaults, as above, setQueryData and getQueryData use that function too. This is because both APIs merge the default options through defaultQueryOptions before hashing. If you put it only on a useQuery call, however, the imperative APIs use the default hashKey, and the same key splits into two slots in the cache. In the v3.2.0 beta period, even the global default was not applied to setQueryData, and the reporter of Issue #1343 confirmed it was fixed in v3.2.0-beta.30.

In production, it is therefore much safer to avoid the escape hatch and convert values into a serializable form when constructing the queryKey. Writing your own hash function means you have to take care of both key sorting and where it is registered.

How Filters Compare Keys

There is one more way two keys are judged equal. The "same key" discussed so far means the hash strings are equal, and hashes are used only for cache lookup and exact: true filters. Filters such as invalidateQueries and findAll decide by default with partialMatchKey, which recursively compares the structure of the original queryKey rather than the hash string. Arrays are matched from the front, and objects are checked only for the keys written in the filter. I ran the code below in the same environment as well.

import { partialMatchKey } from '@tanstack/query-core'
 
const queryKey = ['todos', { status: 'done', page: 1 }]
console.log(partialMatchKey(queryKey, ['todos'])) // true
console.log(partialMatchKey(queryKey, ['todos', { status: 'done' }])) // true
console.log(partialMatchKey(queryKey, [{ status: 'done' }])) // false
console.log(partialMatchKey(queryKey, ['todos', { status: 'todo' }])) // false

Since this matching does not go through the hash, replacing queryKeyHashFn does not change it. In a QueryClient registered with an unsorted hash function like the earlier example, an object that differs only in key order cannot be found with exact: true, but prefix matching still finds it.

So keys that are equal by hash are not guaranteed to be equal in a filter. A query created with ['user', undefined] is not caught by prefix matching with a ['user', null] filter, and a key containing NaN does not even match itself. Conversely, if you put a Date or a Map into a filter, it has no enumerable properties to compare, so it matches any Date or object in the same position.

Conclusion

In short, TanStack Query does not compare queryKey array references. Instead, hashKey sorts plain object keys while serializing with JSON.stringify, and the resulting string (queryHash) becomes the key of a Map. As a result, object key order does not affect the cache, array element order does, and a property whose value is undefined is, for hashing, the same as an absent one. Most values that JSON cannot represent turn into other values without any error, so different keys silently become the same key. You can swap the hash function with queryKeyHashFn, but that also throws away key sorting, so it is safer to convert values into serializable ones when building the key. Filters such as invalidation, by default, skip the hash and match the structure of the queryKey from the front, so it is better not to expect keys that are equal by hash to be equal in a filter. In the end, cache lookups compare a key's content through hashKey and filters compare its leading part, so if you put only simple values whose meaning survives serialization into a queryKey, both comparisons behave as you expect.

How this rule carries over to writing and managing queryKeys, that is, the path from inline arrays through query key factories to queryOptions, is covered in queryKey.

Next time you put an object or a Map into a queryKey, I hope you will stop for a moment and think about what string it will be serialized into.

참고 자료

Comments