Skip to content
ayoubb.dev/blog/react-query-keys-as-a-prefix-tree

How I write React Query keys

In TanStack Query, invalidateQueries({ queryKey }) refreshes every cached query whose key starts with the key you pass. So the shape of your keys decides which screens a mutation can reach.

I keep every key in one file and build them as a prefix tree. Each kind of data has one root, and every key under it starts with that root. If the cache needs refreshing, there's always a prefix that covers exactly the right queries.

This builds on how I lay out React Query code: a key registry, a transport layer, (args, options?) hooks and components that never touch a key. That post introduces the registry. This one is about the shape of the keys inside it.

What goes wrong without a tree

Keys usually start out as inline arrays written by whoever needs them:

useQuery({ queryKey: ["tasks", projectId, filters], ... });
useQuery({ queryKey: ["task-counts", projectId], ... });
useQuery({ queryKey: ["project-tasks-infinite", projectId], ... });

These are three roots for one kind of data. Invalidating ["tasks"] refreshes the first and quietly leaves the counts and the infinite list stale. To catch the other two, people start writing predicates:

queryClient.invalidateQueries({
  predicate: (query) =>
    ["tasks", "task-counts", "project-task"].includes(query.queryKey[0] as string),
});

Nothing checks that list. Spot the bug: the third root is project-tasks-infinite, not project-task, and the predicate fails silently by matching nothing. Every new screen that reads tasks has to be added to every predicate like this one, and some always get missed.

One root per domain

Every domain gets an all() key, and every other key in it spreads all() first:

query-keys.ts
export const queryKeys = {
  projects: {
    all: () => ["projects"] as const,
    list: (params: ProjectListParams) =>
      [...queryKeys.projects.all(), "list", params] as const,
    detail: (projectId: number) =>
      [...queryKeys.projects.all(), "detail", projectId] as const,
  },
};

So invalidateQueries({ queryKey: queryKeys.projects.all() }) reaches every project query because of how the keys are built, not because someone remembered to add it.

The root is the domain's name in kebab-case, so projects becomes "projects" and teamMembers becomes "team-members". Once you know a domain's name, you know its root, and two domains can't end up sharing one.

A domain is a kind of data, not a screen

It's tempting to add a domain named after a screen, like taskDrawer, holding everything the drawer fetches. Then a mutation that edits a task invalidates taskDrawer.all(), the drawer refreshes, and the board showing the same task stays stale.

The drawer reads tasks, comments and users, so it uses keys from those three domains. A mutation invalidates the data it wrote, and every screen that shows that data refreshes, whether it was built before the mutation or after.

The parent id goes second

Most data belongs to something. Tasks belong to a project, and comments belong to a task. When the parent id is always known, I put it right after the root and expose a prefix for it:

query-keys.ts
type ProjectScoped<P> = P & { projectId: number };
 
export const queryKeys = {
  tasks: {
    all: () => ["tasks"] as const,
    forProject: (projectId: number) =>
      [...queryKeys.tasks.all(), projectId] as const,
    list: ({ projectId, ...params }: ProjectScoped<TaskListParams>) =>
      [...queryKeys.tasks.forProject(projectId), "list", params] as const,
    counts: ({ projectId, ...params }: ProjectScoped<TaskCountParams>) =>
      [...queryKeys.tasks.forProject(projectId), "counts", params] as const,
  },
};

Now adding a task to project 42 refreshes tasks.forProject(42), which covers the list, the counts and anything added under it later, while project 7's tasks stay cached.

The id must be a required number. URL state usually gives you number | null, so the key takes ProjectScoped<P> instead of the raw search params, and the type checker makes the caller prove the id exists. If the parent is sometimes missing, keep that domain flat.

Never invalidate with missing arguments

Factories with optional params invite a call that looks right and does nothing:

// list: (params?: ProjectListParams) => [...queryKeys.projects.all(), "list", params]
 
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list() });
// ["projects", "list", undefined]

TanStack compares keys element by element and checks types first. undefined doesn't match an object, so ["projects", "list", undefined] is not a prefix of ["projects", "list", { search: "acme" }]. The call only refreshes a list that was fetched with no params, which is usually none of them. No error, no warning, just a stale list.

If you need all the lists, add a prefix factory that stops before the params, like projects.allLists(), or invalidate all(). A key with params belongs in useQuery, not in an invalidation.

Every input to the fetch is in the key

Two hooks that fetch different things must never share a key. A badge that fetches 100 comments to count them and a panel that fetches the first 10 to show them will overwrite each other's cache entry if both use comments.forTask(id). Whichever ran last decides what the other one renders.

If it changes the request, it goes in the key: page, page size, filters and sort.

One function per set of readers

Some writes affect more than one domain. Moving a task to another project changes both projects' task lists, both project cards, the assignee's "my tasks" page and the dashboard counts.

I don't repeat that list in every mutation. It lives in one function, and every mutation that changes the same data calls it:

invalidate-project-tasks.ts
export function invalidateProjectTasks(queryClient: QueryClient, projectId: number) {
  // Views across projects: marked stale now, refetched in the background.
  void queryClient.invalidateQueries({ queryKey: queryKeys.myTasks.all() });
  void queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
 
  // This project's own views: the caller waits for these.
  return Promise.all([
    queryClient.invalidateQueries({ queryKey: queryKeys.tasks.forProject(projectId) }),
    queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }),
  ]);
}

When a new screen starts reading tasks, it's added here once and every mutation picks it up.

The split between awaited and background is deliberate. invalidateQueries resolves after the active queries refetch, so awaiting everything holds the mutation until the slowest screen on the page has reloaded, even one the user isn't looking at. I only await the views the user just changed.

The mutations become thin wrappers:

use-create-task.ts
export function useCreateTask(
  { projectId }: { projectId: number },
  options?: MutationParamsOptions<Task, NewTask>,
) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (task: NewTask) => createTask(projectId, task),
    ...options,
    // On settle, not on success: a failed batch can still be half written.
    onSettled: async (...args) => {
      await invalidateProjectTasks(queryClient, projectId);
      options?.onSettled?.(...args);
    },
  });
}

Because onSettled awaits the refetch, mutateAsync resolves once the list on screen is correct. A toast.promise at the call site turns green at the same moment the new row appears.

Invalidate, don't patch

I don't write optimistic updates for these mutations. With setQueryData you have to reproduce what the server would return, for every key that shows the data, and roll it back on failure. Each of those is code that can drift from the real response. The worst version writes to a key no query reads, and looks like it works because nothing on screen changes.

The cost is that a row appears after a round trip instead of instantly. For most writes that's a few hundred milliseconds, and I'd rather spend those than keep a second copy of the server's logic in the client. I keep optimistic updates for the few interactions where the delay is noticeable, like toggles and drag and drop.

Make the linter enforce it

Conventions decay, so I enforce the important ones with ESLint's built-in no-restricted-syntax:

eslint.config.mjs
const CACHE_METHODS =
  /^(invalidate|refetch|cancel|remove|reset)Queries$|^(set|get)QueryData$/;
 
export default [
  {
    files: ["src/**/*.{ts,tsx}"],
    ignores: ["src/lib/query-keys.ts"],
    rules: {
      "no-restricted-syntax": [
        "error",
        {
          selector: "Property[key.name='queryKey'] > ArrayExpression",
          message: "Build query keys with queryKeys, not inline arrays.",
        },
        {
          selector: `CallExpression[callee.property.name=${CACHE_METHODS}] > ArrayExpression:first-child`,
          message: "Build query keys with queryKeys, not inline arrays.",
        },
        {
          selector: `CallExpression[callee.property.name=${CACHE_METHODS}] Property[key.name='predicate']`,
          message: "Invalidate a prefix from queryKeys instead of a predicate.",
        },
        {
          selector: "MemberExpression[computed=true][object.property.name='queryKey']",
          message: "Don't match keys by position. Invalidate a prefix instead.",
        },
      ],
    },
  },
];

There's no allow-list. If an invalidation can't be written as a prefix, the domain is shaped wrong, and I reshape it rather than reaching for a predicate.

The rules about the tree itself, one root per domain and every factory spreading all(), are harder to express as selectors. I check them with a small script that walks the queryKeys object, calls each factory with sample arguments and asserts its first element is the domain's kebab-case root.

Test the invalidation, not the mock

The test I find most useful creates a real QueryClient, fills it with the real keys for two projects, runs the mutation for project 42 and checks which entries are invalidated:

invalidate-project-tasks.test.ts
test("invalidates project 42's views and leaves project 7 cached", async () => {
  const queryClient = new QueryClient();
  queryClient.setQueryData(queryKeys.tasks.list({ projectId: 42, page: 1 }), []);
  queryClient.setQueryData(queryKeys.tasks.list({ projectId: 7, page: 1 }), []);
 
  await invalidateProjectTasks(queryClient, 42);
 
  const isInvalidated = (key: QueryKey) => queryClient.getQueryState(key)?.isInvalidated;
  expect(isInvalidated(queryKeys.tasks.list({ projectId: 42, page: 1 }))).toBe(true);
  expect(isInvalidated(queryKeys.tasks.list({ projectId: 7, page: 1 }))).toBe(false);
});

A test that mocks invalidateQueries and checks it was called would pass for the undefined bug above. This one fails.

The rules, in short

  • One file holds every key, and no key is written inline.
  • Each domain has one kebab-case root, and every key spreads all().
  • A domain is a kind of data, never a screen.
  • A parent id that's always known goes second, behind a for<Parent>(id) prefix.
  • Invalidate with prefixes, never with parametrized keys or predicates.
  • Every input to the fetch is part of the key.
  • Writes that share readers share one invalidate* function.