• Home
  • About
  • Agent Skills
  • Projects
  • Blog
  • Contact
Resume
Naser Rasouli

Author

Naser Rasouli

Front-End developer - sharing lessons learned, notes, and write-ups from real projects.

GitHubLinkedIn

Last posts

What Is Codebase Memory MCP? Structural Memory for Coding Agents
2026-08-08•1 min read

What Is Codebase Memory MCP? Structural Memory for Coding Agents

A practical guide to Codebase Memory MCP for building a knowledge graph of your project and helping coding agents understand large frontend codebases faster.

BEM Methodology in CSS: predictable naming for clean styles
2026-02-18•1 min read

BEM Methodology in CSS: predictable naming for clean styles

A practical guide to BEM to avoid style conflicts, structure class names, and keep CSS maintainable.

Why console.log After setState Shows the Old Value
2026-02-04•1 min read

Why console.log After setState Shows the Old Value

React batches state updates, so logging right after setState prints the previous value. Here’s why and the right ways to read the fresh state.

TanStack Query Cache Architecture for React Apps

TanStack Query Cache Architecture for React Apps

2026-09-07
reacttanstack-queryarchitecturecachingserver-state

TanStack Query Cache Architecture for React Apps

TanStack Query rarely becomes difficult because of the library itself. The difficulty appears when every feature invents its own rules for query keys, freshness, invalidation, and prefetching. That leads to duplicate cache entries, unnecessary refetches, and mutations that invalidate far more data than they actually affect.

The goal is not to wrap TanStack Query in another internal framework. The goal is to create a small, explicit contract for server state.

1. Start with server-state ownership

TanStack Query is a good fit when the source of truth lives outside the browser:

API / Server
    ↓
TanStack Query Cache
    ↓
React UI

Modal visibility, a wizard step, and an unsaved local form draft are different kinds of state.

Before copying API data into Redux or Zustand, ask whether the Query Cache already provides the ownership and lifecycle you need.

2. Treat query keys as feature architecture

TanStack Query caches data by queryKey. A key should uniquely describe the data, and variables used by the query function should be represented in the key.

For an orders feature:

export const orderKeys = {
  all: ["orders"] as const,
  lists: () => [...orderKeys.all, "list"] as const,
  list: (filters: OrderFilters) =>
    [...orderKeys.lists(), filters] as const,
  details: () => [...orderKeys.all, "detail"] as const,
  detail: (id: string) =>
    [...orderKeys.details(), id] as const,
}

This creates a predictable hierarchy for both reads and invalidation.

You do not need a large internal query-key framework. A small object colocated with the feature is usually enough.

3. Colocate query contracts with queryOptions

For TypeScript projects, queryOptions is useful because it keeps the query key and query function together while preserving type inference:

import { queryOptions } from "@tanstack/react-query"

export const orderOptions = {
  detail: (id: string) =>
    queryOptions({
      queryKey: orderKeys.detail(id),
      queryFn: () => getOrder(id),
      staleTime: 30_000,
    }),
}

The same contract can be reused by components and imperative QueryClient APIs:

const query = useQuery(orderOptions.detail(orderId))

This is often cleaner than a generic useAppQuery() wrapper that hides the real library API and re-exposes dozens of options.

4. Choose staleTime from the data lifecycle

Cached query data is stale by default. Stale queries can refetch on events such as mount, window focus, and reconnect.

So staleTime should answer:

How long can this value be treated as fresh without another request?

Reference data might stay fresh for much longer:

queryOptions({
  queryKey: ["countries"],
  queryFn: getCountries,
  staleTime: 30 * 60 * 1000,
})

A live order might need a much shorter window:

queryOptions({
  queryKey: ["orders", orderId],
  queryFn: () => getOrder(orderId),
  staleTime: 10_000,
})

A single global stale time for every resource usually hides important differences in data behavior.

5. Invalidate narrowly after mutations

After a mutation succeeds, targeted invalidation is often the simplest correct choice:

const mutation = useMutation({
  mutationFn: updateOrder,
  onSuccess: async (_, variables) => {
    await queryClient.invalidateQueries({
      queryKey: orderKeys.detail(variables.id),
    })

    await queryClient.invalidateQueries({
      queryKey: orderKeys.lists(),
    })
  },
})

Invalidated queries become stale, and active matching queries can refetch in the background.

A broad call such as:

queryClient.invalidateQueries()

can be useful for a full reset, but it should not become the default mutation strategy.

6. Refetching is not always required

If a mutation returns the authoritative updated entity, you can update that cache entry directly:

onSuccess: (updatedOrder) => {
  queryClient.setQueryData(
    orderKeys.detail(updatedOrder.id),
    updatedOrder,
  )
}

For filtered, sorted, or paginated collections, invalidation is often safer than manually patching every cached variant.

A practical split is:

Authoritative entity returned
→ setQueryData

Related collections may have changed
→ invalidateQueries

7. Put prefetching near navigation intent

Prefetching is valuable when the user is likely to need the data soon.

For example:

await queryClient.prefetchQuery(
  orderOptions.detail(orderId),
)

This can happen from a route loader, hover intent, or another predictable navigation signal.

Prefetching the entire application at startup turns an on-demand cache into an eager loading system and often defeats the reason to use it.

8. Do not mirror query data into another global store

A common anti-pattern is:

API
 ↓
TanStack Query
 ↓
Redux/Zustand
 ↓
Component

If the client store is only a copy of Query data, the application now has two sources of truth.

Keep client-owned state in client state tools and keep server-owned state in the Query Cache unless a concrete requirement says otherwise.

9. Keep query contracts close to the owning feature

A simple structure can be enough:

features/orders/
├── api/
│   ├── get-order.ts
│   └── update-order.ts
├── queries/
│   ├── order-keys.ts
│   └── order-options.ts
└── components/
    └── order-details.tsx

The feature owns its cache contract. The QueryClient and provider remain application infrastructure.

10. Review for these cache smells

Inconsistent keys

["order", id]
["orders", id]
["order-detail", id]

Three conventions for one entity make invalidation fragile.

staleTime: Infinity without a lifecycle reason

It can be valid for data that only changes through explicit invalidation, but it should not be used as a shortcut to avoid understanding freshness.

Generic wrappers around every query

If a wrapper mostly mirrors the original API, it adds migration cost without adding a real architectural boundary.

Broad invalidation

Mutations should communicate which cache domains they affect.

Duplicated server state

Copying Query data into another store creates synchronization work.

A small team contract

For each feature, answer:

1. Which feature owns the data?
2. What is the query-key hierarchy?
3. Are query-function variables represented in the key?
4. Does staleTime match the data lifecycle?
5. Which keys can each mutation change?
6. Should we setQueryData or invalidate?
7. Is prefetch tied to real navigation intent?
8. Is server data being copied into another store?

Those questions prevent many cache bugs before they appear.

Conclusion

Good TanStack Query architecture usually means fewer custom abstractions:

Feature ownership
      ↓
Stable query keys
      ↓
Co-located query options
      ↓
Intentional staleTime
      ↓
Targeted invalidation
      ↓
No duplicated server state

With those boundaries in place, the Query Cache becomes a predictable part of React architecture instead of a collection of unrelated fetch calls.

References

  • https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
  • https://tanstack.com/query/latest/docs/framework/react/guides/query-options
  • https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
  • https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
  • https://tanstack.com/query/latest/docs/framework/react/guides/prefetching