This episode unpacks query keys as cache identity: hierarchical arrays, keys with filter objects, and how query keys power deduplication and invalidation. You also understand structural sharing and how to avoid double fetches.

In episode 4 you used queryKey as a magic box. Episode 5 opens that box: a query key is actually the cache identity. Everything in TanStack Query — storage, deduplication, invalidation — rests on the query key. If you misunderstand keys, the advanced features that follow will feel like unpredictable magic.
Episode 5 covers a good query key structure, how the cache and deduplication work, and structural sharing, which preserves performance.
A query key is an array that can contain strings, numbers, objects, or a combination of them. This array is deterministically hashed into the identity of a cache entry:
["todos"]
["todos", 5]
["todos", "detail", 5]
["todos", { status: "done" }]
["users", 42, { sortBy: "name" }]["todos", 5] identifies a different cache than ["todos", 6]. Objects are also distinguished: ["todos", { status: "done" }] is a different entry from ["todos", { status: "pending" }]. As long as the serialized array is the same, the cache is the same.
Keys are arranged hierarchically — the first element is the domain, and the second element onward is more specific identification. This hierarchy isn't just a style choice: it enables prefix invalidation. Mentioning ["todos"] can target every key that starts with ["todos", ...].
useQuery({ queryKey: ["todos"], queryFn: fetchTodos })
useQuery({ queryKey: ["todos", id], queryFn: fetchTodoById })
useQuery({
queryKey: ["todos", id, "comments"],
queryFn: fetchTodoComments,
})All the keys above start with the ["todos"] prefix. With queryClient.invalidateQueries({ queryKey: ["todos"] }), all three can be refetched at once — this pattern will become the backbone of synchronization in episode 6.
queryFn result.["todos", 5] and [5, "todos"] are different caches.When two components use an identical query key, TanStack Query detects that the cache entry is the same and merges them into one fetch. This differs from the manual pattern, where every mounted component fetches on its own.
function Header() {
const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser })
return <p>Halo, {data?.name}</p>
}
function Sidebar() {
const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser })
return <img src={data?.avatar} alt="avatar" />
}Even though Header and Sidebar both call fetchUser, the network request only happens once. The identical queryKey: ["user"] in both components makes the library merge their observers into one cache entry — that's deduplication.
A cache entry is born the first time it is requested, lives as long as there are observers or it is still within gcTime, and is discarded if it goes unused beyond gcTime. Once an entry is discarded, any queryClient.setQueryData reference pointing to it becomes useless — the data must be fetched again.
Sometimes you need to read or write the cache without waiting for a query:
const cached = queryClient.getQueryData(["todos"])
queryClient.setQueryData(["todos"], (old) => (old ?? []).concat(newTodo))queryClient.getQueryData(["todos"]) reads the cache data without fetching, and queryClient.setQueryData writes it directly. Both methods will be heavily used for optimistic updates in episode 11.
When the cache is updated, TanStack Query compares the old and new data structures. If a part is structurally identical, that part keeps referencing the same object. This keeps React.memo and useMemo working optimally because unchanged props don't trigger re-renders.
const oldData = { todos: [{ id: 1 }], meta: { page: 1 } }
const newData = { todos: [{ id: 1 }], meta: { page: 2 } }
oldData.todos === newData.todos // trueThe comparison oldData.todos === newData.todos evaluates to true even though the parent object is new — the unchanged todos array keeps its reference. structuralSharing is enabled by default and can be turned off per query if you really need a new object every time.
For very large data, structural sharing does a deep compare that can feel heavy. TanStack Query provides a way out: set structuralSharing: false on specific queries whose data always changes, or provide a custom function. For most cases, leave the default because the benefits far outweigh the costs.
Tip
Query key structure is a design decision, not just a habit. A good key design (domain → id → sub-resource) makes invalidation easier and the cache easier to reason about. Take the time to design your key conventions before your application grows large.
Episode 5 explained that the query key is the cache identity, not just a label. You now understand hierarchical arrays and filter objects, how deduplication merges fetches, the lifecycle of a cache entry, and structural sharing, which preserves render performance.
Key takeaways:
getQueryData and setQueryData for direct cache access.In the next episode, episode 6, we will discuss mutations — write operations with useMutation, the isPending and isError statuses, and how to sync the cache with invalidateQueries and setQueryData after a successful mutation. This is the bridge between reading and writing data.