Telerik blogs

Check out what might be the cleanest way to manage query keys at scale, patterns to use and why this an underrated part of using TanStack Query.

Throughout this TanStack Query series (including the posts on data fetching, mutations, optimistic updates and pagination), we’ve been writing query keys as plain inline arrays: ['users'], ['user', userId], ['posts']. That works perfectly fine when you have three or four queries in a small app. It starts to fall apart somewhere between 50 and 100 queries, which is a number you reach faster than you’d think.

Today I want to walk through what I’ve found to be the cleanest way to manage query keys at scale, the patterns I reach for, and why I think this is one of the most underrated parts of using TanStack Query well.

Why Query Keys Matter More Than You Think

Let’s start with the why. A query key is the unique address of a piece of cached data, and it’s also the handle you use for everything you do to that piece of data: refetching, invalidating, prefetching, reading directly from the cache.

When your keys are scattered around as inline arrays, a few things tend to happen:

  • You misspell a key in an invalidateQueries call and silently nothing happens. The list doesn’t refresh, you don’t see an error, you just see stale data.
  • You change the shape of a key (say, from ['user', id] to ['users', 'detail', id]) and now have to track down every single place that referenced the old shape.
  • You can’t tell at a glance which queries are related to which entity, so invalidation strategies become guesswork.
  • New developers on the team have no central place to look up: “how do we cache users in this app?”

The patterns below address all four. They’re not mandatory, plenty of small apps get along just fine without them. But once your codebase is large enough that you’ve forgotten about half the queries you’ve written, they pay for themselves in a single afternoon.

The Naive Approach (and Where It Hurts)

Here’s what most apps look like before they hit the wall:

// In UserList.vue
useQuery({ queryKey: ['users'], queryFn: fetchUsers })

// In UserDetail.vue
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId.value),
})

// In a mutation somewhere
queryClient.invalidateQueries({ queryKey: ['users'] })
queryClient.invalidateQueries({ queryKey: ['user'] })

Notice the inconsistency already creeping in. Is it 'users' or 'user'? Plural for the list, singular for the detail? Six months from now when someone adds a “user posts” query, are they going to use ['user', userId, 'posts'] or ['userPosts', userId]? Both are reasonable. Both are wrong, only because they’re inconsistent with what the rest of the team chose.

This is the meat of the problem. Query keys are a contract, and contracts only work if everyone speaks the same language.

Query Key Factories

The pattern that saves us is the query key factory: a single object per entity that owns every key shape for that entity. This convention was popularized by Dominik (TanStack Query’s maintainer) in his Effective React Query Keys post, and it has become something of a community standard. The shape below is the one I keep coming back to.

queryKeys/users.js

export const userKeys = {
  all: ['users'],
  lists: () => [...userKeys.all, 'list'],
  list: (filters) => [...userKeys.lists(), { filters }],
  details: () => [...userKeys.all, 'detail'],
  detail: (id) => [...userKeys.details(), id],
}

Now, anywhere we want to use a user-related query key, we import this object. There is exactly one source of truth for the shape.

import { userKeys } from '@/queryKeys/users'

// In UserList.vue
useQuery({
  queryKey: userKeys.list({ status: 'active' }),
  queryFn: fetchUsers,
})

// In UserDetail.vue
useQuery({
  queryKey: userKeys.detail(userId),
  queryFn: () => fetchUser(userId.value),
})

// In a mutation
queryClient.invalidateQueries({ queryKey: userKeys.all })

Let’s break it down. The shape of the factory is doing a lot of work:

  • all: The root key for everything user-related. Invalidating this invalidates every user query in the cache.
  • lists(): The root for any list of users. Invalidating this invalidates every list, regardless of filters, but leaves details alone.
  • list(filters): A specific list with a specific set of filters. Each unique filter set gets its own cache entry.
  • details(): The root for any detail view.
  • detail(id): A specific user’s detail.

Notice how each more specific key is composed from the more general one above it. That’s the secret sauce.

Hierarchical Invalidation

The whole reason for nesting keys this way is what TanStack Query calls partial matching. When you call invalidateQueries({ queryKey: someKey }), every cached query whose key starts with someKey gets invalidated.

So with the factory above:

  • invalidateQueries({ queryKey: userKeys.all }) invalidates lists and details and anything else nested under users. Useful after a bulk operation.
  • invalidateQueries({ queryKey: userKeys.lists() }) invalidates only the list queries, regardless of filters. Useful after creating a new user, since detail pages don’t need a refresh.
  • invalidateQueries({ queryKey: userKeys.detail(userId) }) invalidates only that one user’s detail. Useful after editing one specific user.

That last one is what unlocks really nice mutation patterns:

const updateUser = useMutation({
  mutationFn: patchUser,
  onSuccess: (updated) => {
    queryClient.invalidateQueries({ queryKey: userKeys.detail(updated.id) })
    queryClient.invalidateQueries({ queryKey: userKeys.lists() })
  },
})

We invalidate the specific detail (it changed) and any list view (the row in the list might be showing the old name). We do not invalidate every other user’s detail page, because they didn’t change. Surgical and predictable.

A Convention That Scales

Here’s the convention I default to. Each entity gets its own factory file under queryKeys/. Each factory has a consistent shape:

export const xxxKeys = {
  all: ['xxx'],
  lists: () => [...xxxKeys.all, 'list'],
  list: (params) => [...xxxKeys.lists(), { params }],
  details: () => [...xxxKeys.all, 'detail'],
  detail: (id) => [...xxxKeys.details(), id],
  // entity-specific keys go below
}

For nested resources, just add a nested function:

export const userKeys = {
  all: ['users'],
  lists: () => [...userKeys.all, 'list'],
  list: (filters) => [...userKeys.lists(), { filters }],
  details: () => [...userKeys.all, 'detail'],
  detail: (id) => [...userKeys.details(), id],
  posts: (userId) => [...userKeys.detail(userId), 'posts'],
}

Now userKeys.posts(1) evaluates to ['users', 'detail', 1, 'posts']. Invalidating userKeys.detail(1) will also invalidate the user’s posts query, because of partial matching. That nested ownership is exactly what we want for cross-entity dependencies.

Filter Objects as Plain Objects

Notice that I’m passing filters as an object: { filters: { status: 'active', search: 'ada' } }. TanStack Query hashes query keys deterministically, so two equivalent filter objects produce the same hash regardless of property order. You don’t need to sort the keys yourself.

What you do need to be careful about is using stable values. Don’t pass a fresh object literal that contains a Date instance or a function reference, because those won’t hash to the same value across renders. Stick to JSON-serializable plain data, and you’ll be fine.

A common mistake I see is passing a reactive ref directly:

useQuery({
  queryKey: userKeys.list(filtersRef),
  queryFn: ...,
})

That works because TanStack Query unwraps refs in the key array, but if filtersRef is a ref to an object, you want to make sure that object identity actually changes when the contents change, otherwise the query won’t refetch. When in doubt, pass filtersRef.value and let your factory take a plain object.

TypeScript: A Bonus Win

If you’re on TypeScript, query key factories give you free type safety on query keys. Every key shape is a tuple type, and the factory functions can be typed to require valid arguments. This is one of those quiet quality of life improvements that you don’t appreciate until you accidentally pass a string where a number was expected and the compiler catches it for you.

export const userKeys = {
  all: ['users'] as const,
  detail: (id: number) => [...userKeys.all, 'detail', id] as const,
}

The as const is what makes the tuple type tight enough to be useful. Without it, you’d just get string[] and lose the structural typing.

Where I Keep These Files

For team conventions, here’s the layout I default to:

src/
  queryKeys/
    users.js
    posts.js
    comments.js
    index.js
  queries/
    useUserList.js
    useUserDetail.js
  mutations/
    useCreateUser.js
    useUpdateUser.js

The queryKeys/ folder is the authoritative list of what gets cached in the app. The queries/ and mutations/ folders wrap useQuery and useMutation per use case, so components never call them directly. Components just import useUserList() and get back a clean composable.

This isn’t strictly necessary, but I’ve found it scales better than scattering useQuery calls throughout components. When a query needs to change, you change one file. When you want to know what queries exist, you look in one folder.

A Working Example: A Real Mutation Flow

Let’s tie everything together with a realistic example. Imagine we’re editing a user’s profile and we want to:

  1. Optimistically update the user’s detail in the cache.
  2. Invalidate the user’s details and any list that contains them, but nothing else.
  3. Leave other entities (posts, comments) untouched.

useUpdateUser.js

import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { userKeys } from '@/queryKeys/users'

export const useUpdateUser = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (user) => {
      const response = await fetch(`https://myapp.com/users/${user.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(user),
      })
      if (!response.ok) {
        throw new Error('Failed to update user')
      }
      return response.json()
    },
    onMutate: async (user) => {
      const detailKey = userKeys.detail(user.id)
      await queryClient.cancelQueries({ queryKey: detailKey })
      const previous = queryClient.getQueryData(detailKey)
      queryClient.setQueryData(detailKey, (old) => ({ ...old, ...user }))
      return { previous, detailKey }
    },
    onError: (err, user, context) => {
      queryClient.setQueryData(context.detailKey, context.previous)
    },
    onSettled: (data, error, user) => {
      queryClient.invalidateQueries({ queryKey: userKeys.detail(user.id) })
      queryClient.invalidateQueries({ queryKey: userKeys.lists() })
    },
  })
}

Notice how clean this is. Every key comes from the factory, the optimistic write goes to a precise location, and the invalidation surgically targets the detail and the lists without touching anything else. There’s no string typo bug waiting to happen, and if we ever rename 'users' to something else, we change one line in queryKeys/users.js and the entire app keeps working.

This is the payoff. The investment in factories looks like overhead at first, but it actively prevents the most common class of bugs in larger TanStack Query codebases.

When to Skip All This

Big disclaimer, though: If you have eight queries in a small app, you do not need a query key factory. Just write ['users'] inline and move on with your life. Premature abstraction is real, and a key factory for a tiny app is overkill.

I usually start reaching for factories when I notice one of the following:

  • I’m writing the same key shape in three or more places.
  • I’m not sure what the right key for a given query should be without checking what someone else wrote.
  • I’ve had a bug where an invalidation didn’t fire because the key was slightly off.

Any one of those is the signal that it’s time to extract.

Wrapping Up

Query keys are the contract that holds your cache together. Treat them casually, and you’ll spend afternoons hunting down stale data and missed invalidations. Treat them as a first-class concern, with a factory per entity and a consistent shape, and you get hierarchical invalidation, type safety and a single source of truth for every query in your app.

The official query keys guide and Effective React Query Keys by Dominik (TanStack Query’s maintainer) are both worth reading. The post is React-flavored, but every single pattern translates one-to-one to Vue.

Happy querying!


About the Author

Marina Mosti

Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.

Related Posts

Comments

Comments are disabled in preview mode.