Skip to content

Data fetching · Open source

Query Key Kit

Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.

TanStack Query v5 v0.1.3 Free · MIT
Query Key Kit: Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.

What it solves

TanStack Query v5's queryOptions() keeps one key next to its fetcher, but nothing says which keys exist, how they nest or which partial key to pass when you invalidate.

Teams hand-write key arrays, or use @lukemorales/query-key-factory, which has had no release since February 2024 (1.3.4) and drops every option except queryKey and queryFn.

createQueryKeys(scope, schema) turns a plain object of entries into a key tree. Each entry gives back an object you can pass straight to useQuery, fetchQuery or useInfiniteQuery: the full queryKey, branded with the data type the way queryOptions() brands it, the queryFn, and every other option you wrote (staleTime, placeholderData, initialData, initialPageParam and so on). Nested contextQueries extend a parent key under _ctx. Every node of the tree carries its partial key, so invalidateScope, refetchScope, cancelScope, removeScope, getScopeData and setScopeData take the node instead of a hand-written array, and the data helpers know the type of every query they match. It keeps the function names and key output of @lukemorales/query-key-factory, with a few differences listed in the migration section. Tested with React in a real app and from a fresh npm install, and with Vue Query through type tests. An independent package, not made or endorsed by TanStack or by the author of @lukemorales/query-key-factory.

  • createQueryKeys builds a typed key tree from four entry kinds: null, a key tuple, an options object or a function returning either
  • Entries produce queryOptions()-shaped objects with a DataTag-branded queryKey, so getQueryData, setQueryData and fetchQuery return your data type without a cast
  • Every query option written on an entry is passed through unchanged, including staleTime, placeholderData, initialData and the infinite-query options
  • contextQueries nest child queries under a parent key, reached through _ctx
  • Scope helpers (invalidateScope, refetchScope, cancelScope, removeScope, getScopeData, setScopeData) take any query node of the tree and use its partial key as a prefix match; the client is typed as query-core's QueryClient; mutation entries and createMutationKeys scopes are a type error and throw at run time
  • createQueryKeyStore, mergeQueryKeys and createMutationKeys, with mutationFn kept as written so useMutation infers variables and result; mergeQueryKeys throws if two scopes define the same entry name
  • Type helpers: inferQueryKeys, inferQueryKeyStore, inferQueryData, inferScopeData and TypedUseQueryOptions
  • No runtime dependencies, ESM only, marked side-effect free; peer dependency @tanstack/query-core ^5.0.0, type-checked against 5.0.0; the built dist/index.d.ts is checked with TypeScript 5.0.4 against query-core 5.0.0
  • One 6.7 kB JavaScript file in the 0.1.3 npm package (2.1 kB gzipped)

Install

Requires TanStack Query v5.

npm install @amjadiqbal/query-key-kit

Usage

import { createQueryKeys, invalidateScope } from '@amjadiqbal/query-key-kit';

export const todos = createQueryKeys('todos', {
  all: null,
  detail: (id: number) => ({
    queryKey: [id],
    queryFn: () => api.getTodo(id),
    staleTime: 30_000,
    contextQueries: {
      comments: { queryKey: null, queryFn: () => api.getComments(id) },
    },
  }),
  list: (filters: TodoFilters) => ({
    queryKey: [{ filters }],
    queryFn: () => api.getTodos(filters),
  }),
});

useQuery(todos.detail(1));               // key ['todos', 'detail', 1], data: Todo | undefined
useQuery(todos.detail(1)._ctx.comments); // key ['todos', 'detail', 1, 'comments']

invalidateScope(queryClient, todos.list);      // every todos.list(...) query
invalidateScope(queryClient, todos.detail(1)); // detail 1 and its comments
invalidateScope(queryClient, todos);           // everything under 'todos'

Setup

Install the package next to the TanStack Query v5 adapter you already use, for example @tanstack/react-query, which brings @tanstack/query-core. The package has no runtime dependencies of its own; its only peer dependency is @tanstack/query-core ^5.0.0. It ships as an ES module only. The lowest TypeScript version it is checked with is 5.0.4, against @tanstack/query-core 5.0.0; newer query-core releases use NoInfer in their own types, which needs TypeScript 5.4 or later.

  1. 1 npm install @amjadiqbal/query-key-kit
  2. 2 Declare a scope per feature with createQueryKeys('todos', { ... }), or every scope at once with createQueryKeyStore({ ... }).
  3. 3 Pass entries to your hooks and QueryClient calls: useQuery(todos.detail(1)), queryClient.fetchQuery(todos.list(filters)).
  4. 4 Invalidate by node instead of by array: invalidateScope(queryClient, todos.list).

The key tree

createQueryKeys(scope, schema) prefixes every key with the scope name and the entry name, in the order you declared them. The scope itself gets _def: [scope]. createQueryKeys(scope) with no schema returns just { _def: [scope] }.

  • null: all: null gives { queryKey: ['todos', 'all'] }.
  • Key tuple: count: ['total'] gives { _def: ['todos', 'count'], queryKey: ['todos', 'count', 'total'] }.
  • Options object: stats: { queryKey: null, queryFn, staleTime } gives query options with key ['todos', 'stats']. With a non-null queryKey the entry also gets _def.
  • Function: detail: (id: number) => ({ queryKey: [id], queryFn }) gives a function whose result has key ['todos', 'detail', id]. The function itself carries _def: ['todos', 'detail'].
  • contextQueries on an options object is a nested schema whose keys extend that entry's key, exposed as _ctx: todos.detail(1)._ctx.comments has key ['todos', 'detail', 1, 'comments'].
  • Names starting with an underscore are reserved for _def and _ctx. They are a type error, and createQueryKeys throws at run time if one gets through.
  • createQueryKeyStore({ users: null, todos: { ... } }) builds every scope in one object. mergeQueryKeys(users, todos, todoMutations) combines scopes you declared separately, keyed by scope name. Scopes with the same name, typically a query scope and a mutation scope, are combined into one. If two of them define the same entry name, mergeQueryKeys throws instead of letting one replace the other.

What an entry gives you

An entry produces a plain object shaped like the result of TanStack's queryOptions(), so it goes straight into useQuery, useSuspenseQuery, useQueries, useInfiniteQuery, fetchQuery, prefetchQuery or ensureQueryData. queryFn receives the full prefixed key in ctx.queryKey. Every option other than queryKey, queryFn and contextQueries is copied through as written.

  • The queryKey is branded with the queryFn's return type, so queryClient.getQueryData(todos.detail(1).queryKey) is Todo | undefined. Entries without a queryFn have an unbranded key and unknown data.
  • Infinite queries: give the entry initialPageParam and getNextPageParam. queryFn then gets pageParam, and the cached data type is InfiniteData<Page>.
  • skipToken can be used as the queryFn.
  • An entry with initialData gives defined data from useQuery.
  • Callbacks written inside an entry (getNextPageParam, select, placeholderData as a function) need their parameter annotated, because the entry is not yet tied to a hook.
  • To change options at the call site, spread the entry: useQuery({ ...todos.detail(id), enabled: !!id }), or queryOptions({ ...todos.detail(id), select }). Do not wrap the entry in queryOptions() inside createQueryKeys.
const titleQuery = queryOptions({ ...todos.detail(id), select: (todo) => todo.title });
const feed = infiniteQueryOptions({ ...todos.feed(filters), maxPages: 3 });

Scoped invalidation and cache updates

Each helper takes a QueryClient and a node of the tree, works out the node's partial key and calls the matching QueryClient method with { ...filters, queryKey }. TanStack matches that key as a prefix, so a node covers itself and everything below it. The partial key is queryKey for an entry, a called entry or a context query, and _def for a scope or an uncalled function entry. Passing a raw array, or a queryKey inside filters, is a type error. The client is typed as query-core's QueryClient, narrowed to the one method each helper calls, so adapter clients that extend it fit; a type test passes Vue Query's client. filters takes query-core's filter options for that method, minus queryKey.

  • invalidateScope(client, node, filters?, options?) calls invalidateQueries.
  • refetchScope(client, node, filters?, options?) calls refetchQueries.
  • cancelScope(client, node, filters?, options?) calls cancelQueries.
  • removeScope(client, node, filters?) calls removeQueries.
  • getScopeData(client, node, filters?) calls getQueriesData and returns [queryKey, data] pairs, typed.
  • setScopeData(client, node, updater, filters?) calls setQueriesData with a typed updater, for optimistic updates of every matching query at once.
  • scopeKey(node) returns the partial key itself.
  • todos matches everything under ['todos']; todos.list matches every todos.list(...) call; todos.detail(1) matches detail 1 and its context queries; add { exact: true } to match only that one query.
  • The data type of a node is the union of every query it matches, context queries included, and unknown when a matched entry has no queryFn.
  • Query nodes only: mutation entries (static, function or called) and scopes made only of mutations are a type error, because a mutation key used as a query filter would match unrelated queries. At run time scopeKey, and so every helper, throws for the same mutation entries and for a scope returned by createMutationKeys(scope, schema). createMutationKeys(scope) with no schema returns a plain { _def: [scope] } and is accepted by both. A mutation-only scope that went through mergeQueryKeys under its own name is a type error but no longer throws, because the run-time marker is not copied by the merge. A merged scope that holds both queries and mutations is accepted.
await cancelScope(queryClient, todos.list);
setScopeData(queryClient, todos.list, (old) =>
  old?.map((t) => (t.id === id ? { ...t, done: true } : t)), // old: Todo[] | undefined
);

Mutations

createMutationKeys(scope, schema) works like createQueryKeys with mutationKey and contextMutations. mutationFn and any other option are passed through with their own types, so useMutation(todoMutations.create) infers the variables and the result. A mutation scope can share its name with a query scope and be combined with mergeQueryKeys, as long as the entry names differ. Mutation entries and mutation scopes cannot be passed to the scope helpers, and throw if they reach one at run time; invalidate the query node instead, as below.

export const todoMutations = createMutationKeys('todos', {
  create: { mutationKey: null, mutationFn: (input: NewTodo) => api.createTodo(input) },
  toggle: (id: number) => ({ mutationKey: [id], mutationFn: (done: boolean) => api.setDone(id, done) }),
});

const toggle = useMutation({
  ...todoMutations.toggle(1),
  onSuccess: () => invalidateScope(queryClient, todos.detail(1)),
});

Type helpers

Keys are inferred as exact tuples, such as readonly ['todos', 'detail', number]. These types pull keys and data out of the tree for code that lives outside it.

  • inferQueryKeys<typeof todos>: the key tree (queryKey, _def, _ctx) without functions or other options.
  • inferQueryKeyStore<typeof queries>: the same for a whole store, for example to type a QueryFunctionContext declared elsewhere.
  • inferQueryData<typeof todos.detail>: the data of one entry.
  • inferScopeData<typeof todos.detail>: the data of every query under a node.
  • TypedUseQueryOptions<typeof todos.detail>: observer options for custom hooks, built on query-core's QueryObserverOptions. An optional second argument sets the data type after select: TypedUseQueryOptions<typeof todos.detail, string>.

Migrating from @lukemorales/query-key-factory

The same seven exports exist with the same names: createQueryKeys, createQueryKeyStore, mergeQueryKeys, createMutationKeys, inferQueryKeys, inferQueryKeyStore and TypedUseQueryOptions, the same schema input (null, key tuples, options objects, functions, contextQueries) and the same output keys (queryKey, queryFn, _def, _ctx). For most code, swapping the import and running the type checker is the migration. It is not a byte-for-byte copy, and these differences can need changes:

  1. 1 npm uninstall @lukemorales/query-key-factory && npm install @amjadiqbal/query-key-kit
  2. 2 Replace the import path @lukemorales/query-key-factory with @amjadiqbal/query-key-kit.
  3. 3 Run your type checker; code that relied on React-only fields of TypedUseQueryOptions or on an unknown error type may need edits.
  4. 4 Review entries whose options were previously ignored, since they now apply.
  5. 5 Check that no two scopes passed to mergeQueryKeys share an entry name, since mergeQueryKeys now throws when it runs.
  • Options are kept. The old package dropped everything except queryKey and queryFn, so staleTime, placeholderData, initialData and the rest now take effect. Options that silently did nothing before will start working.
  • TypedUseQueryOptions<Entry, Data> takes the same two type arguments (Data is optional), but is built on query-core's QueryObserverOptions instead of React Query's UseQueryOptions, and its error type is DefaultError instead of unknown.
  • inferQueryKeys returns only keys (queryKey, _def, _ctx), not the other options.
  • Keys are DataTag-branded, so getQueryData and similar calls return your data type instead of unknown.
  • Entries keep declaration order instead of being sorted, and objects have a normal prototype instead of Object.create(null). The keys themselves are unchanged.
  • mergeQueryKeys throws when two merged scopes define the same entry name. The old package let the later one win without a warning.
  • New in this package: the scope helpers, inferQueryData, inferScopeData, pageParam in queryFn, and typed mutation variables.

Compatibility and testing

The package imports only types from @tanstack/query-core and needs @tanstack/query-core ^5.0.0; it is type-checked against 5.0.0 as well as a current 5.x. The library is type-checked with TypeScript 7.0, and the local CI script also compiles a consumer file against the built dist/index.d.ts with TypeScript 5.0.4 against @tanstack/query-core 5.0.0, with skipLibCheck off, so the .d.ts itself is checked. Newer query-core releases need TypeScript 5.4 or later for their own types. It has been tested with React in a real app and from a fresh npm install, and with Vue Query through type tests. Solid Query, Svelte Query and Angular Query have not been tested in a real app. Because entries are plain query-core option objects, the same way queryOptions() output is, they should work with those adapters too, but that is untested.

  • React Query: type tests cover useQuery, useSuspenseQuery, useInfiniteQuery, useQueries and useMutation, plus spreading entries into queryOptions() and infiniteQueryOptions().
  • Vue Query: type tests check that useQuery accepts an entry and infers its data, and that the scope helpers accept Vue Query's QueryClient.
  • Unit tests run every entry kind, context queries, stores, merging, mutations and each scope helper against a real QueryClient from @tanstack/query-core, including mergeQueryKeys throwing on a duplicate entry, scopeKey throwing for static, function and called mutation entries and for a createMutationKeys scope, and scopeKey accepting a merged query and mutation scope.
  • Type tests check that mutation entries and mutation-only scopes are rejected by the scope helpers, and that a merged query and mutation scope is accepted.
  • A module that exports its keys compiles with declaration: true.
  • The repository has a Vite and React example app (examples/react, not in the npm package). It uses createQueryKeys (null, function and infinite entries, contextQueries, staleTime, placeholderData), createMutationKeys, mergeQueryKeys, inferQueryKeyStore, useQuery, useInfiniteQuery, useMutation, scopeKey, invalidateScope, cancelScope, setScopeData, getScopeData and typed getQueryData. It does not use createQueryKeyStore, refetchScope, removeScope, TypedUseQueryOptions, skipToken, initialData, useSuspenseQuery or useQueries, which only the unit and type tests cover. npm run check:example packs the package, installs the tarball into it and type-checks it; this is a separate command from the local CI script.
  • Solid, Svelte and Angular: no tests of any kind yet.

Credits

The API follows @lukemorales/query-key-factory by Luke Morales (MIT). The code is a new implementation for TanStack Query v5, not a fork. Query Key Kit is an independent package and is not affiliated with or endorsed by TanStack or by Luke Morales.

Hire me

Need this built into your app, or something custom?

I wrote Query Key Kit. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

Need something built?

Free quote in 60 seconds