TanStack Query Best Practices: The Patterns That Separate 8% CTR from 0.5% Load Times

TanStack Query best practices: query key organization, staleTime vs gcTime, prefetching, infinite queries, SSR hydration, and the caching patterns that make apps feel instant. 49 impressions, 8.16% CTR.

Huifer
Huifer
September 18, 20263 min read


title: "TanStack Query Best Practices: The Patterns That Separate 8% CTR from 0.5% Load Times" description: "TanStack Query best practices: query key organization, staleTime vs gcTime, prefetching, infinite queries, SSR hydration, and the caching patterns that make apps feel instant. 49 impressions, 8.16% CTR." author: "Huifer" authorUrl: "https://tanstackship.com/about" date: "2026-09-18" lastUpdated: "2026-09-18" tags: ["TanStack Query", "Best Practices", "React", "Caching", "Performance"] readTime: "10 min read" slug: "tanstack-query-best-practices-2026" canonical: "https://tanstackship.com/blog/tanstack-query-best-practices-2026" profile: "how-to-guide" eeat: rule: word_count: 2100 word_count_pts: 8 hero_block_pts: 4 heading_structure_pts: 3 internal_links_pts: 3 code_blocks_pts: 2 total: 20 llm: experience: 18 expertise: 18 authoritativeness: 18 trustworthiness: 18 total: 72 rationale: "Author has used TanStack Query in 5+ production apps with query key conventions, SSR hydration, and cache invalidation patterns refined across multiple projects. Describes specific performance wins from query key organization and prefetching patterns." total: 92 passed: true weak_signals: ["Best practices may shift with future TanStack Query versions"] strong_signals: ["Specific query key convention with code", "staleTime vs gcTime with production examples", "Prefetching patterns with exact code", "SSR hydration code with v5 improvements"] legacy_total: 92 core_eeat: framework: "CORE-EEAT" profile: "blog-post" catalog_version: "18.0.0" observed_at: "2026-09-18" verdict: "SHIP" status: "DONE" score_state: "SCORED" raw_overall_score: 92 final_overall_score: 92 veto_count: 0 cap_applied: false evidence_coverage: 100 score_confidence: "high" run_json: "tanstack-query-best-practices-2026.core-eeat.run.json" vetoes: 0 coverage: 100 dimension_scores: C: 92 O: 90 R: 94 E: 93 Exp: 91 Ept: 88 A: 88 T: 90


Written by Huifer, solo developer and maintainer of TanStack Ship. The TanStack Query best practices I use today are not the ones I started with. My first TanStack Query implementation had query keys like ['getUser', userId], ['fetchProduct'], and ['products'] — a naming convention that worked until I had 30 queries and no idea which ones were stale. I spent a day standardizing the query key convention across three apps and it was the highest-leverage refactor I did all year. This article is the convention I now use in every new app.

Verified sources: TanStack Query documentation · TanStack Query best practices · TanStack Query GitHub Last updated: 2026-09-18 · Changelog

TL;DR: The three highest-leverage TanStack Query practices: (1) Query key convention with dots — ['users', userId, 'profile']; (2) staleTime set to the data's natural refresh interval, not 0; (3) Prefetch critical routes on link hover. Everything else is optimization after those three.


Practice 1: Query Key Convention

The most important convention in a TanStack Query app is the query key. Query keys determine cache identity — two queries with the same key share the same cached data. A bad convention makes cache invalidation unpredictable. A good convention makes it obvious.

The Dot-Separated Convention

typescript
// Every query key follows: [entity, id?, subResource?]
// This makes invalidation predictable and keys predictable

// Single resource by ID
['users', userId]
['products', productId]
['orders', orderId]

// Sub-resources of a single entity
['users', userId, 'profile']
['users', userId, 'orders']
['products', productId, 'reviews']

// Collections (no ID)
['users']
['products']
['orders']

// Filtered collections
['products', 'category', categorySlug]
['orders', 'status', 'pending']

This convention has one rule: entity names are plural, IDs are singular, sub-resources are singular. ['users', '123'] is a user. ['users', '123', 'orders'] is the orders of that user.

Why This Matters for Invalidation

With this convention, invalidation is predictable:

typescript
// Invalidate all queries for a specific user
queryClient.invalidateQueries({ queryKey: ['users', userId] })
// Invalidates: ['users', userId], ['users', userId, 'profile'], ['users', userId, 'orders']

// Invalidate all queries for a specific product's reviews
queryClient.invalidateQueries({ queryKey: ['products', productId, 'reviews'] })

// Invalidate all queries for a resource type
queryClient.invalidateQueries({ queryKey: ['products'] })
// Invalidates: ['products'], ['products', 'category', ...], ['products', productId, ...]

Without a convention, you never know which queries are affected by an invalidation. With the convention, the query key structure tells you exactly.


Practice 2: staleTime, Not 0

The default staleTime: 0 means every query refetches on every mount. For data that changes frequently (notifications, live metrics), this is correct. For data that changes rarely (user profile, product catalog), this is a performance tax.

typescript
// ❌ Default: refetch on every mount — expensive
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  // staleTime defaults to 0
})

// ✅ Set staleTime to the data's natural refresh interval
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 60 * 5, // 5 minutes — user profile rarely changes
})

// ✅ Static reference data: never refetch unless explicitly invalidated
const { data: countries } = useQuery({
  queryKey: ['reference', 'countries'],
  queryFn: () => fetchCountries(),
  staleTime: 1000 * 60 * 60 * 24, // 24 hours
})

The mental model: staleTime is how long you are comfortable serving stale data. For user profile data, 5 minutes is fine. For a stock price, 0 seconds. For a country list, infinity.


Practice 3: Prefetch on Link Hover

The highest-impact UX pattern in TanStack Query: prefetch data when the user hovers over a link. The data is in the cache by the time they click.

typescript
import { useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'

function ProductList({ products }) {
  const queryClient = useQueryClient()

  return (
    <div className="product-grid">
      {products.map(product => (
        <Link
          key={product.id}
          to={`/products/${product.id}`}
          onMouseEnter={() => {
            // Prefetch when user hovers — data is ready before click
            queryClient.prefetchQuery({
              queryKey: ['products', product.id],
              queryFn: () => fetchProduct(product.id),
              staleTime: 1000 * 60 * 5,
            })
          }}
        >
          <ProductCard product={product} />
        </Link>
      ))}
    </div>
  )
}

The result: clicking a product card renders the detail page instantly — the query data is already in the cache. The onMouseEnter fires before the navigation, giving you 50–200ms of prefetch time.


Practice 4: Global QueryClient Defaults

Set global defaults that match your data's refresh characteristics, not edge cases:

typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Default: 2 minutes stale time
      // This is the right default for most SaaS data
      staleTime: 1000 * 60 * 2,

      // gcTime: 10 minutes
      // Data lives in memory for 10 minutes after last subscriber
      gcTime: 1000 * 60 * 10,

      // Retry 3 times with exponential backoff
      retry: 3,
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 30000),

      // Refetch on window focus for authenticated users
      // (data might have changed while tab was inactive)
      refetchOnWindowFocus: true,

      // Don't refetch if already loading
      refetchOnMount: false,
    },
    mutations: {
      // Default retry: 0 for mutations
      // Mutations should fail loudly, not retry silently
      retry: 0,
    },
  },
})

Practice 5: SSR Hydration with v5

For SSR apps, the hydration pattern changed in v5. Ensure server and client states align:

typescript
// Server: prefetch with consistent staleTime
// (must match the client's staleTime for v5 hydration to work)
const dehydratedState = dehydrate(
  new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,
        gcTime: 1000 * 60 * 10,
      },
    },
  })
)
typescript
// Client: match server's staleTime in QueryClient config
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          // Must match server's staleTime for correct hydration
          staleTime: 1000 * 60 * 5,
          gcTime: 1000 * 60 * 10,
        },
      },
    })
)

The v5 improvement: if the server data is fresh (within staleTime), the client uses it directly without a refetch. Mismatched staleTime values between server and client cause silent refetches on hydration.


Practice 6: Background Refetch Strategy

Don't refetch everything on every focus event. Use staleTime as the primary refetch control and background refetch sparingly:

typescript
const { data, isFetching, isStale } = useQuery({
  queryKey: ['dashboard-metrics'],
  queryFn: () => fetchDashboardMetrics(),
  staleTime: 1000 * 60, // 1 minute
  // Only refetch if the query is currently stale AND the window has focus
  refetchOnWindowFocus: (query) => query.state.data !== undefined,
})

The refetchOnWindowFocus function form: only refetch if we already have data. This prevents the "loading state on every tab switch" UX anti-pattern.


The 5-Point Production Checklist

  • Query key convention documented — every team member uses the same structure
  • staleTime set per-query — 0 is the exception, not the default
  • Critical routes prefetched — product detail and user profile pages on link hover
  • SSR staleTime matches client — run a hydration smoke test on every SSR change
  • QueryClient gcTime is 10+ minutes — too low and back-navigation feels slow

TanStack Ship ships with TanStack Query v5 with the query key convention and SSR hydration preconfigured. See the TanStack Query v5 guide and the full feature list.