DEV Community

Cover image for The best pattern for Tanstack Query in a Big App
Ignacio Martin Vazquez
Ignacio Martin Vazquez

Posted on • Edited on

The best pattern for Tanstack Query in a Big App

TanStack Query is excellent at fetching and caching server state.
But in real applications, teams eventually hit the same wall:

  • cache keys become inconsistent
  • invalidation logic spreads everywhere
  • mutations silently fail to refresh the right data

The problem isn’t TanStack Query.

The problem is that cache key management is left entirely up to the application.

This post shows the pattern we ended up formalizing to fix that — the same one documented in Query Cache Flow.

The real pain points (from production)

In larger React apps, we kept running into the same four issues:

  1. Manual cache keys are error-prone
    Cache keys are just arrays. Typos and mismatches are easy and invisible.

  2. Invalidation logic is hard to reason about
    After a mutation, it’s unclear which queries should be invalidated — lists, details, filters, or all of them.

  3. Generated hooks don’t solve cache consistency
    Even with OpenAPI + codegen (e.g. KUBB), cache keys and invalidation are still manual.

  4. No shared standard across the team
    Every developer ends up inventing their own conventions.

These issues don’t show up immediately — they surface weeks later as stale UI bugs.

The idea: stop inventing cache keys by hand

Instead of manually defining cache keys everywhere, we wanted:

  • a single source of truth for cache keys
  • predictable invalidation behavior
  • something that integrates naturally with generated hooks

That led to a simple rule:

Cache keys and invalidation should be generated, not handwritten.

The pipeline (as documented)

Query Cache Flow formalizes a pipeline that already exists in many teams:

REST API
→ OpenAPI spec
→ KUBB (or other codegen)
→ autogenerated query wrappers
→ zero-thought usage

Cache behavior becomes part of the contract, not an afterthought.

The core primitive: createQueryGroupCRUD

From the docs, everything starts with a query group.

Example taken directly from the documented approach:

import { createQueryGroupCRUD } from '@/queries'

export const accountsQueryGroup =
  createQueryGroupCRUD<string>('accounts')
Enter fullscreen mode Exit fullscreen mode

This single line defines:

  • all query keys related to accounts
  • how CRUD operations invalidate related queries
  • a consistent structure for lists and details

No strings. No duplication.

Using it with generated hooks

Instead of calling generated hooks directly, they’re wrapped once with a stable query key:

export const useAccounts = () =>
  generatedUseAccounts({
    query: {
      queryKey: [accountsQueryGroup.list.queryKey],
    },
  })
Enter fullscreen mode Exit fullscreen mode

Key points:

  • the query key comes from the query group
  • every consumer uses the same key structure
  • no one invents keys in components anymore

Mutations and automatic invalidation

This is where the pattern pays off.

After a mutation:

await createAccount.mutateAsync(data)
Enter fullscreen mode Exit fullscreen mode

Invalidation is not guessed or re-implemented per mutation.

Instead:

invalidateQueriesForKeys([
  accountsQueryGroup.create.invalidates,
])
Enter fullscreen mode Exit fullscreen mode

That invalidation key already knows:

  • which lists must refresh
  • which related queries are affected

This produces automatic invalidation cascades without repeating logic.

Why this works better than ad-hoc invalidation

Compare this with the typical approach:

queryClient.invalidateQueries({ queryKey: ['accounts'] })
queryClient.invalidateQueries({ queryKey: ['accounts', id] })

Enter fullscreen mode Exit fullscreen mode

Problems:

  • keys are duplicated everywhere
  • easy to forget one
  • hard to review or refactor

With query groups:

  • invalidation intent is explicit
  • behavior is centralized
  • reviews become trivial

What this pattern gives you

Query Cache Flow provides:

  • Consistent cache key structure
  • Automatic cascade invalidation
  • Type-safe cache operations
  • First-class integration with code-generated hooks

Most importantly:

You stop thinking about cache keys entirely.

When this pattern makes sense

This approach is ideal if:

  • you use OpenAPI + codegen
  • you have list/detail relationships
  • multiple mutations affect the same data
  • you care about long-term maintainability

If your app is tiny, you don’t need this.

If your app grows, you eventually do.

This is a pattern, not magic

Query Cache Flow doesn’t:

  • replace understanding TanStack Query
  • hide how invalidation works
  • fix poorly designed APIs

What it does is formalize cache behavior so it stops being implicit knowledge.

Final thought

Cache invalidation is famously hard.

But most of the pain comes from lack of structure, not from React Query itself.

Once cache keys and invalidation are treated as first-class, generated artifacts — the problem becomes boring again.

And boring is exactly what you want here.

Docs:
👉 https://querycacheflow.com/docs/intro

Top comments (0)