Home / Articles / TanStack Query detailed guide: queries, cache, mutations, and optimistic UI

This article is published in English.

TanStack Query detailed guide: queries, cache, mutations, and optimistic UI

TanStack Query manages server state like a restaurant manager: shared cache keys, freshness dials, coordinated mutations, invalidation, and optimistic updates on top of your HTTP client.

5069 words

Today’s topic is TanStack Query (formerly React Query): the library that turns messy server-state fetching into predictable cache, freshness, and mutation workflows. A high-end restaurant analogy makes the moving parts memorable—dining room as UI, kitchen as backend, waiters as HTTP clients, and the manager as TanStack Query.

What is TanStack Query?

In a typical app the UI asks the backend for data with fetch or Axios. Those clients are not very smart about coordination. If five components request the same menu at once, five separate kitchen trips can leave. If someone asks for the soup of the day and another guest asks ten seconds later, a naive waiter walks back to the kitchen again. That overworks the server and slows the room.

TanStack Query acts as the head waiter and restaurant manager: it orchestrates fetching, caching, synchronizing, and updating server state so the kitchen is not harassed with identical questions.

Axios / Fetch vs. TanStack Query

Beginners often think TanStack Query replaces Axios or fetch. It does not.

  • Fetch and Axios are the waiters. They carry a request to the kitchen and bring a response. They do not remember prior trips, judge freshness, or coordinate peers.
  • TanStack Query is the manager. It hires waiters to walk. It remembers what came back, whether it is still considered fresh, which tables share the same notepad, and when to send someone back after the kitchen changes a dish.

You still write queryFn bodies that call Axios or fetch. TanStack Query wraps those calls with cache keys, deduplication, retries, and lifecycle hooks.

What is TanStack Query? (capabilities map)

Six capabilities matter most in day-to-day React work:

1. Queries (useQuery): getting the data

Declarative reads keyed by a queryKey, executed by a queryFn.

2. Caching: the manager’s brain

Results live in memory keyed by the query key so multiple components share one network trip.

3. Freshness: staleTime vs gcTime

staleTime decides when cached data is considered outdated enough to refetch. gcTime (garbage collection) decides how long unused cache entries remain before eviction.

4. Mutations (useMutation): changing the data

Writes—create, update, delete—run on demand rather than on mount.

5. Query invalidation: ripping up the menu

After a successful mutation, mark related queries stale so the UI resyncs with the server.

6. Optimistic updates: the Michelin-star experience

Update the cache immediately, roll back on error, and settle with a definitive sync.

The rest of this guide walks each capability with restaurant scenes and concrete code shapes.

1. Queries (useQuery): getting the menu

A query declares what you want and how to get it:

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

// The waiter function (Axios)
const fetchMenu = async () => {
  const response = await axios.get('/api/menu');
  return response.data;
};

function MenuComponent() {
  // The Manager (TanStack Query) orchestrating the process
  const { data: menu, isLoading, isError, error } = useQuery({
    queryKey: ['menu'], // The label for this specific data
    queryFn: fetchMenu,  // The waiter doing the fetching
  });

  if (isLoading) return <div>Waiter is walking to the kitchen... Loading Menu...</div>;
  if (isError) return <div>The kitchen is on fire! Error: {error.message}</div>;

  return (
    <ul>
      {menu.map((item) => (
        <li key={item.id}>{item.name} - ${item.price}</li>
      ))}
    </ul>
  );
}

The queryKey is the filename on the manager’s notepad—['menu'], ['soup'], ['allergies', tableId]. Identical keys share cache and dedupe in-flight requests. The queryFn is the waiter’s walk: return a promise of data.

While the first fetch runs, isPending / loading flags let the UI show placeholders. Errors surface through isError and error. Successful data appears in data and remains available to every component watching that key.

What is a race condition?

The restaurant analogy

Imagine two guests asking for soup while a slow kitchen responds. A late response for table A must not overwrite table B’s newer request. Without coordination, whichever promise finishes last wins—even if it is stale.

How this happens in React (useEffect)

Manual useEffect fetching often forgets abort logic. Navigate quickly between pages and an older response can set state after a newer request started.

How TanStack Query fixes the race

The library tracks in-flight queries by key, can cancel via AbortSignal when supported, and ensures UI observers see coherent cache transitions instead of ad-hoc setState races.

2. Caching: the manager’s notepad

// Waiter function
const fetchMenu = async () => {
  console.log("Waiter is walking to the kitchen!"); // We can track how many times this runs
  const response = await axios.get('/api/menu');
  return response.data;
};

// Component 1: The Sidebar
function MenuSidebar() {
  const { data } = useQuery({ queryKey: ['menu'], queryFn: fetchMenu });
  return <div>We have {data?.length} items today!</div>;
}

// Component 2: The Main Display
function MenuMainDisplay() {
  const { data } = useQuery({ queryKey: ['menu'], queryFn: fetchMenu });
  return <div>{data?.map(item => <p>{item.name}</p>)}</div>;
}

When the first component mounts with ['menu'], the manager sends a waiter. When a second component mounts with the same key milliseconds later, it reads the notepad instead of walking again. That deduplication is why dashboards with many cards sharing user or config queries feel snappy without custom global stores.

The manager in action (step-by-step)

  1. Component A mounts → cache miss → network.
  2. Response arrives → cache write → A renders.
  3. Component B mounts with same key → cache hit → B renders immediately.
  4. According to freshness rules, a background refetch may occur later without blocking B’s first paint.

Server state belongs in TanStack Query; true client UI state (modal open, selected tab) can stay in React state or a thin client store.

3. Freshness: configuring staleTime and gcTime

1. staleTime: is this information still accurate?

const { data } = useQuery({
  queryKey: ['soup'],
  queryFn: fetchSoup,
  staleTime: 1000 * 60 * 30, // 30 minutes
});

With staleTime: 10_000, data younger than ten seconds is fresh: remounts reuse it without refetch. After it goes stale, observers may trigger background refetch (on remount, window focus, or reconnect—depending on defaults and options). Pick staleTime from domain volatility: soup of the day might be short; country lists might be long.

2. gcTime: can I throw this paper away?

const { data } = useQuery({
  queryKey: ['allergies', 'table4'],
  queryFn: fetchAllergies,
  gcTime: 1000 * 60 * 60 * 24, // Keep in memory for 24 hours
});

gcTime controls how long a cache entry remains after all observers unmount. A short gcTime frees memory faster; a longer one makes revisit navigations instant. Do not confuse it with staleTime: stale data can still sit in memory until garbage-collected.

The ultimate secret: stale-while-revalidate

TanStack Query happily shows stale data while refetching in the background. Users see the last known menu instantly; when the kitchen confirms updates, the notepad refreshes. That pattern is why the library feels faster than spinners on every visit.

4. Mutations (useMutation): adding a new dish

What is a mutation?

A mutation changes server state—placing an order, editing a profile, deleting a comment.

The restaurant analogy: placing an order

Waiters do not place orders automatically when a guest sits down; they wait for a clear request. Mutations are the same: they run when you call mutate or mutateAsync.

The code: building the order form

import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { useState } from 'react';

// 1. The Waiter Function (The actual network request)
const placeOrder = async (orderData) => {
  // We are using POST because we are creating a new order
  const response = await axios.post('/api/orders', orderData);
  return response.data;
};

function OrderForm() {
  const [dish, setDish] = useState('');

  // 2. The Manager orchestrating the mutation
  const mutation = useMutation({
    mutationFn: placeOrder,
    // We can also trigger side effects right here!
    onSuccess: (data) => {
      console.log("Chef says: Order confirmed!", data);
    },
    onError: (error) => {
      console.log("Chef says: We have a problem.", error.message);
    }
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    // 3. Triggering the mutation and passing the variables
    mutation.mutate({ dishName: dish, tableNumber: 4 });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={dish}
        onChange={(e) => setDish(e.target.value)}
        placeholder="What would you like?"
      />

      {/* Notice how we use isPending to disable the button so they don't double-order! */}
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Sending to Kitchen...' : 'Place Order'}
      </button>

      {/* Handling the feedback */}
      {mutation.isError && <p style={{ color: 'red' }}>Failed: {mutation.error.message}</p>}
      {mutation.isSuccess && <p style={{ color: 'green' }}>Order placed successfully!</p>}
    </form>
  );
}

Wire onSuccess to toast feedback, navigation, or invalidation. Use mutateAsync when you need to await completion in submit handlers.

Advanced developer details

1. It doesn’t run automatically

Unlike queries, mutations idle until invoked—preventing accidental writes on render.

2. isPending vs isLoading

In v5, prefer isPending for mutation in-flight state. Align UI disablement with that flag.

3. Preventing the double-click disaster

Disable the submit button while isPending is true so guests cannot fire duplicate orders.

5. Query invalidation: telling the manager to update the notepad

The problem: the outdated notepad

After a chef adds a dish, tables still reading cached menus see yesterday’s list until something refetches.

The solution: query invalidation

import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

function AddDishForm() {
  // 1. Get access to the Manager's office
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newDish) => {
      const response = await axios.post('/api/menu', newDish);
      return response.data;
    },
    // 2. The magic happens HERE in the onSuccess callback
    onSuccess: () => {
      // 3. Tell the Manager to rip up the menu notepad
      queryClient.invalidateQueries({ queryKey: ['menu'] });
      console.log("Menu invalidated! The Manager is getting a fresh copy.");
    },
  });

  // ... form code
}

invalidateQueries marks matching entries stale and triggers refetches for active observers.

What exactly happens when you call invalidateQueries?

Matching queries become stale; mounted observers refetch; unmounted entries wait until next mount (subject to gcTime). The kitchen remains source of truth; the notepad is told to refresh.

The advanced concept: fuzzy matching

Narrow invalidation:

queryClient.invalidateQueries({ queryKey: ['menu', 'lunch'] });

Or invalidate a whole prefix:

// This rips up the breakfast, lunch, and dinner notepads all at once!
queryClient.invalidateQueries({ queryKey: ['menu'] });

Fuzzy prefix matching rips up breakfast, lunch, and dinner notepads when you invalidate ['menu'] broadly—powerful and dangerous. Prefer the narrowest key that keeps UI correct.

The golden rule of mutations

Every successful write should either invalidate the reads that depend on it or surgically update the cache. Leaving reads untouched is how UIs lie after saves.

6. Optimistic updates: the Michelin-star experience

The analogy: the manager’s trust

A trusted manager may pencil the new beer onto the bill before the kitchen confirms—then erase if the tap is empty.

The three pillars of an optimistic update

Inside useMutation:

  1. onMutate: stop conflicting fetches, snapshot the cache, write optimistic data immediately.
  2. onError: restore the snapshot if the server rejects.
  3. onSettled: invalidate (or otherwise sync) so the cache matches the database whether the mutation succeeded or failed.

The code: adding a dish optimistically

import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

function AddDishForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newDish) => {
      const response = await axios.post('/api/menu', newDish);
      return response.data;
    },

    // 1. The millisecond the user clicks submit...
    onMutate: async (newDish) => {
      // A. Cancel any outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['menu'] });

      // B. Take a snapshot of the current menu (The Eraser Backup)
      const previousMenu = queryClient.getQueryData(['menu']);

      // C. Optimistically update the Manager's notepad right now!
      queryClient.setQueryData(['menu'], (oldMenu = []) => {
        // We fake an ID for now, the real ID comes from the database later
        return [...oldMenu, { ...newDish, id: Math.random().toString() }];
      });

      // D. Return the snapshot so onError can use it if things go wrong
      return { previousMenu };
    },

    // 2. If the Kitchen catches on fire...
    onError: (err, newDish, context) => {
      // Use the eraser! Roll back to the snapshot we saved in onMutate
      if (context?.previousMenu) {
        queryClient.setQueryData(['menu'], context.previousMenu);
      }
      console.error("Chef says no! Rolling back.", err);
    },

    // 3. Always run this at the very end, success or fail...
    onSettled: () => {
      // Tell the Manager to get the real, final menu from the database
      queryClient.invalidateQueries({ queryKey: ['menu'] });
    },
  });

  // ... form code
}

Pay attention to cancellation of in-flight queries, snapshot structure, and rollback paths. Optimistic UI feels instant but must never strand the cache when the kitchen says no.

Putting the golden pillars together

  • TanStack Query is an async state manager, not a fetcher. It wraps Axios/fetch to make network chaos predictable.
  • The query key is everything. It drives deduplication, caching, and cross-component sharing.
  • Cache syncing after writes is your job. Use invalidateQueries or carefully built optimistic updates so the client notepad matches the kitchen.

Practical defaults for real apps

Start with sensible staleTime for mostly-static resources (minutes) and short or zero staleTime for user-specific volatile data. Keep queryFn pure and abortable. Centralize keys in factory functions (menuKeys.list(), menuKeys.detail(id)) so invalidation stays typed and consistent. Log cache events in development when diagnosing duplicate fetches. Prefer invalidation over elaborate hand-written cache surgery until a screen truly needs optimistic flair.

Common failure modes

  • Using unstable keys (new object literals each render) busts the cache.
  • Forgetting invalidation after mutations shows ghost data.
  • Setting infinite staleTime without a mutation strategy freezes UIs.
  • Putting all client UI flags into the query cache muddies server-state boundaries.
  • Over-broad invalidation (queryKey: [''] style mistakes) refetches the world.

Avoid those, and the restaurant runs: waiters walk when needed, the manager’s notepad stays coherent, and guests see hot food without watching the kitchen door every ten seconds.

Closing

TanStack Query earns its place by owning server-state lifecycles—reads, freshness, writes, and synchronization—while leaving transport to Axios or fetch. Learn the notepad (keys), the freshness dials (staleTime, gcTime), and the write rituals (mutations, invalidation, optimistic updates). With those, React apps stop reinventing request caches in every useEffect and start behaving like a well-run dining room.

Why the restaurant metaphor keeps paying rent

Network waterfalls feel abstract until you picture five waiters sprinting for the same soup. Deduplication is the manager raising a hand: one walk, many tables served. Stale-while-revalidate is serving the last printed menu while a runner checks the board. Invalidation is ripping pages when the chef changes recipes. Optimistic updates are writing the guest’s order on the check before confirmation—with an eraser ready. When onboarding juniors, walk those scenes before showing TypeScript generics; comprehension sticks.

Integrating with routers and auth

Keys should include tenant or user identity when data is not global: ['menu', restaurantId] or ['allergies', userId]. On logout, clear the cache to avoid leaking notepad pages between guests. With React Router or similar, trigger invalidation on actions that already know which resources changed instead of refetching everything on every navigation.

Testing strategies

Unit-test queryFn mappers independently. In component tests, wrap with QueryClientProvider using a fresh client and retry: false for determinism. Assert that mutations call invalidateQueries with expected keys. For optimistic paths, simulate server errors and assert rollback to the snapshot. Avoid sharing one QueryClient across unrelated tests without reset.

Performance notes

Large lists belong behind pagination or infinite queries rather than one mega-key. Selectors (select) let components observe slices without rerendering on unrelated cache fields. Keep queryFn results serializable and stable. Measure refetch storms when refetchOnWindowFocus meets very short staleTime on busy dashboards—tune per query rather than globally.

Migration mindset from raw useEffect

Replace mount effects that set loading/error/data triples with useQuery. Replace imperative POST handlers with useMutation. Delete homemade caches. Keep Axios instances for interceptors and auth headers; pass them into queryFn. The migration is incremental: one screen at a time still yields fewer race bugs immediately.

Final checklist before shipping a feature

  1. Stable, hierarchical queryKey.
  2. Explicit staleTime chosen for the domain.
  3. Mutation paired with invalidation or optimism.
  4. Pending UI disables duplicate submits.
  5. Error toasts or boundaries wired.
  6. Auth-scoped keys cleared on session end.

Hit those six, and TanStack Query stops being “another library” and becomes the quiet manager your dining room needed.

Walkthrough: loading a menu across three components

Picture a header that shows today’s soup, a sidebar that lists lunch specials, and a main pane that renders the full menu. Without TanStack Query each pane might mount its own useEffect and hit /api/menu. With a shared queryKey: ['menu', restaurantId] the first mount pays for the network walk; the others read the notepad. When the chef patches the soup via an admin form using useMutation, invalidating ['menu', restaurantId] refreshes every pane still on screen. Guests never see three divergent soups because three waiters disagreed.

That single story contains deduplication, shared cache, mutation, and invalidation. Most production screens are variations on it: profile header plus settings form, cart badge plus checkout line items, notification bell plus notification page.

Designing query keys like file paths

Treat keys as hierarchical paths:

  • ['menu', restaurantId]
  • ['menu', restaurantId, 'lunch']
  • ['menu', restaurantId, 'item', itemId]
  • ['allergies', restaurantId, tableId]

Factories help:

Invalidating ['menu', restaurantId] can fuzzy-match deeper keys when configured to do so, which is how one save updates list and detail views together. Avoid embedding non-serializable values (functions, class instances) inside keys. Prefer primitive ids and stable enums.

Choosing staleTime with product language

Ask product owners how wrong the UI may be for N seconds. Marketing copy that changes monthly can tolerate long freshness. Inventory counts during flash sales may need near-zero staleTime plus invalidation on every purchase mutation. Document the choice next to the query so future editors do not “optimize” a volatile query into a long stale window.

gcTime is a memory dial. Mobile apps with many routes benefit from retaining recent screens briefly so back navigation feels instant. Extremely large caches on low-memory devices need shorter gcTime or pagination.

Mutations that feel safe

Always surface pending and error states. Disable destructive buttons while pending. For deletes, optimistic removal should restore the snapshot if the server returns 409 or 500. For creates, optimistic rows need temporary client ids replaced by server ids on success—or skip optimism and invalidate instead when id mapping is painful.

Parallel mutations on the same key can fight; queue them or disable controls. mutateAsync in form libraries should live inside submit handlers with try/catch, not in render.

Invalidation patterns that scale

After login, invalidate user-scoped keys rather than clearing the entire client if public content should remain. After logout, queryClient.clear() is usually correct. When a websocket announces “menu changed,” call the same invalidation helpers the HTTP mutation uses so both paths share one sync strategy.

Prefetch on hover for likely detail pages: queryClient.prefetchQuery({ queryKey, queryFn }) turns perceived latency into cache hits without changing screen code.

Optimistic updates without mythology

Optimism is not mandatory for every POST. Use it when the happy path is common, the UI benefit is obvious, and rollback is easy to express. Skip it when server validation is complex or when the response body is required to render (server-generated ids, prices, tax). A slow spinner can be kinder than a flash of wrong data.

When you do use optimism, keep snapshots immutable, cancel conflicting queries in onMutate, and always sync in onSettled. Log rollbacks in development; silent rollbacks confuse QA.

How this compares to global client stores

Redux or Zustand can hold server data, but you will recreate caches, request deduplication, and background refresh. TanStack Query specializes in that niche. Keep ephemeral UI in local state or a small client store; keep server entities in query cache. Crossing those streams leads to duplicated sources of truth.

Teaching the team

Run a dojo: build a tiny menu app with list query, detail query, create mutation, invalidation, then optimistic create. Require key factories and a logout clear. Once that pattern is muscle memory, larger apps stop accumulating useEffect fetch bugs.

Recap table in prose

Queries read. Mutations write. Keys name cache rows. staleTime answers “may I reuse without asking the kitchen?” gcTime answers “may I discard this notepad page?” Invalidation answers “the kitchen changed—refresh.” Optimism answers “update the bill now, erase if rejected.” Transport remains Axios or fetch. That division of labor is the entire product.

End-to-end scenario: lunch specials board

A restaurant opens the lunch shift. The specials board query uses queryKey: ['menu', restaurantId, 'lunch'] with staleTime of two minutes because chalkboards change slowly within a shift. The header soup widget uses ['menu', restaurantId, 'soup'] with a thirty-second stale window. Both queryFns call the same Axios instance with auth interceptors. When an admin saves a new soup via useMutation, onSuccess invalidates both keys—or invalidates the shared prefix ['menu', restaurantId] if fuzzy matching is intentional. Guests on every open tablet see updates without manual refresh.

If the admin form used a hand-rolled fetch without invalidation, tablets would lie until remount. That lie is the bug TanStack Query exists to prevent.

Query key factories in TypeScript

Centralize keys:

  • menuKeys.all(restaurantId)
  • menuKeys.lunch(restaurantId)
  • menuKeys.item(restaurantId, itemId)

Factories prevent typos and make invalidation searchable in the codebase. Prefer tuples of primitives. When filters exist, include serialized filter objects with stable key ordering. Never put the entire options object from props into the key unless it is memoized and serializable.

useQuery options you will actually touch

Beyond queryKey and queryFn: enabled gates fetches until ids exist; retry controls transient failure policy; refetchOnWindowFocus can be disabled for expensive dashboards; placeholderData or initialData keep layouts stable; select narrows subscribed data to reduce rerenders. Defaults are fine to start; tune per query when profiles show refetch storms.

Understanding stale-while-revalidate in UX terms

Showing yesterday’s cart total for 100 ms while refetching may be unacceptable; showing yesterday’s help-center article for a minute is fine. Encode that in staleTime, not in ad-hoc flags. Background refetch failure should not clear good stale data unless you explicitly choose to; users prefer slightly old truth over a spinner error flash when offline.

Mutations: anatomy of a solid form submit

Disable the button on pending; show inline errors from error; on success invalidate or update cache; on settle clear local form state if appropriate. Use mutateAsync with try/catch inside the form library submit handler. Do not call mutate in loops without concurrency control. For uploads, surface progress separately—TanStack Query tracks mutation status, not byte progress.

Invalidation granularity stories

Too narrow: update list but forget detail → detail page stale. Too wide: every key under ['menu'] refetches → thundering herd. Match granularity to screens that can show inconsistency. When in doubt, invalidate the list and the detail id you changed. Prefix invalidation is for intentional fan-out.

Optimistic update pitfalls

Snapshot must deep-clone enough structure to restore nested lists. Temporary client ids must not leak to the server. If multiple optimistic mutations interleave, rollbacks can clobber each other—serialize UI for those flows. Always reconcile with server truth on settle even after success, because the server may normalize fields you did not send.

React Strict Mode and double mount

In development, Strict Mode double-invokes effects. TanStack Query deduplicates by key so you should not see double network calls for the same key in flight. If you do, your key is unstable or queryFn identity issues are breaking dedupe assumptions. Log keys when debugging.

SSR and hydration notes

For Next.js and similar, dehydrate the query client on the server and hydrate on the client so the notepad survives navigation. Ensure queryFns run in both environments or provide server-prefetch that fills the cache before render. Mismatched data shapes between server and client cause hydration warnings that look like framework bugs but are cache seeding bugs.

Comparison with hand-rolled SWR patterns

Many teams reinvent subsets of this library: cache maps, focus refetch, mutate+revalidate. TanStack Query standardizes those battles with community defaults and Devtools. Hand-rolling is justified only for tiny apps or exotic runtimes. Otherwise the manager metaphor wins on hours not spent.

Devtools and observability

React Query Devtools show keys, staleness, observers, and fetch status. Teach the team to read them before adding console logs. In production, scrub sensitive data from error reporters; log query failures with key names, not full payloads, when privacy requires.

Anti-patterns checklist

Unstable keys; missing invalidation; infinite stale with no mutation sync; stuffing UI flags into server cache; over-broad invalidation; optimistic updates without rollback; ignoring enabled until ids exist; using mutations for reads. Avoid these and the dining room stays orderly.

Recap for skimmers

Waiters transport. Managers remember and coordinate. Keys name notepad pages. Freshness dials control reuse. Mutations write. Invalidation and optimism keep the notepad honest. That is TanStack Query in one breath—and why it wraps Axios rather than replacing it.

Extra kitchen drills for practice

Rebuild a tiny app: list query, detail query, create mutation with invalidation, then optimistic create with forced error path. Add a logout that clears the client. Add a prefetch on list-item hover. Measure network calls in Devtools before and after shared keys. These drills encode the library better than reading API tables alone.

When reviewing PRs, ask: what is the key? what is staleTime and why? what invalidates after writes? Is pending UI disabling double submits? If those answers are crisp, the feature will behave under concurrency and navigation stress.

Prefetch patterns that feel instant

Prefetch on route hover, on tab focus before the user clicks, or after login for the default dashboard query. Prefetch fills the cache without mounting an observer. When the user navigates, useQuery finds warm data and skips the loading skeleton. Prefetch the wrong key and you burn bandwidth; prefetch the right key and perceived performance jumps without changing queryFn code.

Pair prefetch with realistic staleTime. Prefetching data that is immediately stale triggers an instant background refetch—still better than a cold start, but not free. Prefetch critical path queries; leave rare settings screens alone.

Dependent queries and waterfall control

When detail needs an id from a list selection, gate with enabled: !!selectedId. When a second query needs data from the first, chain carefully: either nest the second key with the first result id, or use a single queryFn that returns both shapes if the API supports it. Waterfalls hurt TTI; parallel queries with shared auth headers are preferable when independence allows.

Suspense mode changes how loading boundaries compose. If the team uses Suspense, align error boundaries and ensure query errors throw as expected. Mixed Suspense and classic loading flags confuse reviewers—pick one style per route tree.

Pagination, infinite queries, and cache pages

List pages often use page params in the key: ['orders', { page, pageSize, status }]. Changing page creates a new cache entry; keep previous data with placeholderData: keepPreviousData (or the current API equivalent) so the table does not flash empty. Infinite queries append pages; invalidate carefully so you do not wipe scroll position unexpectedly. When a mutation edits one row, patch that page entry or invalidate the whole list depending on sort order sensitivity.

Error recovery and retry UX

Default retries help flaky mobile networks. For 401/403, disable retries and route to login. For 404 on detail, fail fast. Surface failureCount and failureReason in support overlays for power users. Global QueryCache listeners can toast on error once per key rather than once per observer—avoid toast storms when five components share a failing query.

Testing strategies

In unit tests, wrap with a fresh QueryClient with retry: false and short garbage collection. Mock queryFn or use MSW. Assert loading, success, and error states. For mutations, assert that invalidateQueries was called with the expected key. Integration tests should verify that two components sharing a key do not double-fetch. Flaky tests often come from leftover cache between cases—create a new client per test.

Version upgrades and API drift

TanStack Query v4 to v5 renamed some options and shifted defaults. When upgrading, read the migration guide, update Devtools package, and re-check keepPreviousData / placeholderData usage. Pin versions in lockfiles. Treat query key shape changes as breaking: old hydrated caches may not match new keys after deploy—accept a one-time cold cache or version the key prefix.

Field notes from production incidents

One team saw duplicate POSTs because the submit button stayed enabled while isPending was true on a different mutation instance. Another wiped the entire cache on logout incorrectly by creating a new client without clearing the old provider reference. A third encoded user objects in keys and broke structural sharing. Write these stories into onboarding docs so newcomers inherit scars without rediscovering them.

Document your house defaults: default staleTime, which queries are user-scoped, how logout clears state, and when optimistic updates are allowed. Consistency beats cleverness.

Field notes from production incidents

One team saw duplicate POSTs because the submit button stayed enabled while isPending was true on a different mutation instance. Another wiped the entire cache on logout incorrectly by creating a new client without clearing the old provider reference. A third encoded user objects in keys and broke structural sharing. Write these stories into onboarding docs so newcomers inherit scars without rediscovering them.

Document your house defaults: default staleTime, which queries are user-scoped, how logout clears state, and when optimistic updates are allowed. Consistency beats cleverness.

Field notes from production incidents

One team saw duplicate POSTs because the submit button stayed enabled while isPending was true on a different mutation instance. Another wiped the entire cache on logout incorrectly by creating a new client without clearing the old provider reference. A third encoded user objects in keys and broke structural sharing. Write these stories into onboarding docs so newcomers inherit scars without rediscovering them.

Document your house defaults: default staleTime, which queries are user-scoped, how logout clears state, and when optimistic updates are allowed. Consistency beats cleverness.