WebMriya
Back to articles

React Query Cache Invalidation: Stop Refetching Everything

A practical guide to React Query cache invalidation: update the right data, avoid stale screens, and prevent unnecessary network requests.

React
TypeScript
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
October 8, 2026 · 9 min read

A user edits a profile, returns to the dashboard, and sees the old name. The team adds a global refetch after every mutation. The screen becomes correct, but every small update now triggers requests for notifications, permissions, reports, and unrelated lists. Network traffic grows, loading indicators flash everywhere, and nobody can explain which data is actually stale.

This is usually not a React rendering problem. It is a cache ownership problem. A server-state library such as TanStack Query can keep data fresh, but only if the application models query identity and mutation effects precisely. Invalidating everything is safe in the same way that restarting every service after one configuration change is safe: it works, but it hides a poor dependency model.

Cache invalidation is a dependency problem

Every query has a key that describes the server data it represents. A query for one project, one page of tasks, or one user's permissions should not be treated as an anonymous request. Its key is the contract between the UI and the cache.

ts
const projectQuery = useQuery({
  queryKey: ["project", projectId],
  queryFn: () => api.projects.get(projectId),
});

const tasksQuery = useQuery({
  queryKey: ["tasks", { projectId, page, filter }],
  queryFn: () => api.tasks.list({ projectId, page, filter }),
});

The first key says that the result belongs to a particular project. The second says that it belongs to a project, page, and filter combination. If a mutation changes a project's name, the project query is directly affected. A filtered task page probably is not. If a task changes status, several task lists may be affected, but the project metadata still is not.

Use stable query-key factories

Scattered string arrays are easy to mistype. One component may use ["tasks", projectId], while another uses ["project-tasks", projectId]. Both look reasonable, but invalidating one will not touch the other. Query-key factories make the cache vocabulary explicit and keep invalidation aligned with reads.

ts
export const taskKeys = {
  all: ["tasks"] as const,
  lists: () => [...taskKeys.all, "list"] as const,
  list: (params: TaskListParams) => [...taskKeys.lists(), params] as const,
  detail: (taskId: string) => [...taskKeys.all, "detail", taskId] as const,
};

The hierarchy is useful because TanStack Query can match prefixes. Invalidating taskKeys.all targets every task query. Invalidating taskKeys.lists() targets lists but leaves detail queries alone. Invalidating one generated list key targets only that parameter combination.

Choose the smallest correct invalidation

The right invalidation scope depends on what the server mutation can change. Do not choose a broad key merely because it is convenient; choose it because the mutation's effects justify it.

MutationUsually affectedUsually unaffected
Rename a projectProject detail and project listsTask pages, notifications
Change task statusTask detail, relevant task lists, countsProject description
Update current user's avatarCurrent-user profile and visible user summariesUnrelated project data

For a project rename, invalidate the project detail and project-list prefixes. For a task status change, invalidate task lists and any aggregate query that displays counts. This is more work than calling invalidateQueries() once at the application root, but it preserves predictable loading behavior and reduces unnecessary requests.

Invalidate versus update directly

Invalidation marks matching queries stale and lets active queries refetch. That is the safest default when the server applies business rules, calculates fields, or returns data that is difficult to reproduce locally. A direct cache update is appropriate when the mutation response is authoritative and has the same shape as the cached entity.

ts
const updateTask = useMutation({
  mutationFn: api.tasks.update,
  onSuccess: (updatedTask) => {
    queryClient.setQueryData(
      taskKeys.detail(updatedTask.id),
      updatedTask,
    );
    queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
  },
});

Here the detail cache is updated immediately from the response, while lists are invalidated because sorting, filtering, pagination, or derived labels may have changed. This hybrid approach is often better than choosing between “update everything manually” and “refetch everything.”

Be careful with list queries

Lists are where cache invalidation becomes subtle. One entity can appear in several lists: all tasks, assigned tasks, overdue tasks, and a paginated search result. Updating one detail query does not automatically move the entity between those lists. If the mutation changes a field used for filtering or sorting, invalidate the affected list family rather than trying to patch every possible page.

If the result is a simple append to a known list, setQueryData can be efficient. If the operation affects ordering, permissions, totals, or server-side filtering, a targeted refetch is usually more reliable. A cache update that looks correct for the first page can silently produce wrong data on page three.

Avoid invalidation races

Two mutations can finish in a different order from the order in which the user started them. If each success handler writes partial data into the cache, an older response can overwrite a newer one. This is especially common with autosave, editable tables, and rapid status changes.

  1. Send a version, updatedAt value, or mutation identifier when the API supports conflict detection.
  2. Cancel or serialize overlapping mutations when the operation is not safe to run concurrently.
  3. Use the server response as the source of truth instead of merging stale local fields into it.
  4. Refetch the affected query after a complex or conflicting mutation.

The same principle applies to optimistic updates. They can make an interface feel immediate, but they require rollback and reconciliation. For a deeper treatment of failed requests and overlapping mutations, see React Optimistic Updates Without Data Corruption.

A practical review checklist

  • Can you identify the exact query keys a mutation may affect?
  • Are query keys generated from one shared vocabulary?
  • Does the mutation response have enough information for a safe direct cache update?
  • Could filtering, sorting, pagination, permissions, or aggregate counts have changed?
  • Can two requests finish out of order?
  • Are inactive queries being invalidated unnecessarily?
  • Do tests verify cache behavior, not only the mutation request?

Runtime validation remains important at this boundary. A typed mutation response is not proof that the server returned the shape the cache expects. Validate external data before writing it into the client cache; the reasoning is the same as at any other API boundary. See TypeScript Runtime Validation: Close the Gap at API Boundaries.

The useful rule

Invalidate by domain effect, not by component location. A mutation should describe which server concepts it can change, and query keys should make those concepts addressable. When the dependency model is explicit, cache updates become a small design decision instead of a global emergency mechanism.

This kind of state and data-flow design matters most in complex applications with many screens, permissions, and concurrent interactions. If an existing frontend has grown around broad refetches and unpredictable stale data, WebMriya’s complex application development work can help bring those boundaries back under control.

Frequently asked questions

Should every mutation invalidate all queries?

No. Invalidate the smallest query-key scope that covers the mutation's real server-side effects. Broad invalidation is acceptable as a temporary safety measure, but it should not be the permanent data-flow model.

When should I use setQueryData instead of invalidation?

Use setQueryData when the mutation response is authoritative and matches the cached entity shape. Prefer invalidation when server-side sorting, filtering, permissions, aggregates, or business rules may have changed.

Why are query-key factories useful?

They provide one shared vocabulary for reading and invalidating cached data. They also make hierarchical invalidation predictable, so a team can target all lists, one list family, or one entity without relying on copied string literals.