Loading...
Loading...
TanStack Query v5 expert guidance - migration gotchas (v4→v5 breaking changes), performance pitfalls (infinite refetch loops, staleness traps), and decision frameworks (when NOT to use queries, SWR vs React Query trade-offs). Use when: (1) debugging v4→v5 migration errors (gcTime, isPending, throwOnError), (2) infinite refetch loops, (3) SSR hydration mismatches, (4) choosing between React Query vs SWR vs fetch, (5) optimistic update patterns not working. NOT for basic setup (see official docs). Focuses on non-obvious decisions and patterns that cause production issues. Triggers: React Query, TanStack Query, v5 migration, refetch loop, stale data, SSR hydration, query invalidation, optimistic updates debugging.
npx skill4agent add acedergren/agentic-tools tanstack-queryuseQueryNeed data fetching?
│
├─ Data from URL (search params, path) → DON'T use queries
│ └─ Use framework loaders (Next.js, Remix) or URL state
│ WHY: Queries cache by key, URL is already your cache key
│
├─ Derived/computed data → DON'T use queries
│ └─ Use useMemo or Zustand
│ WHY: No server, no stale data, no refetch needed
│
├─ Form state → DON'T use queries
│ └─ Use React Hook Form or controlled state
│ WHY: Forms are local state, not server cache
│
├─ WebSocket/realtime data → MAYBE use queries
│ ├─ High-frequency updates (> 1/sec) → DON'T use queries (use Zustand)
│ └─ Low-frequency (<1/min) → Use queries with manual updates
│ WHY: Queries designed for request/response, not streaming
│
└─ REST/GraphQL server state → USE queries
(This is what React Query is for)cacheTimegcTime// WRONG - v4 syntax, silently ignored in v5
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 10 * 60 * 1000, // ❌ Ignored in v5
})
// CORRECT - v5 syntax
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 10 * 60 * 1000, // ✅ Garbage collection time
})anyisLoadingisPending// WRONG - v4 syntax
const { isLoading } = useQuery(...)
if (isLoading) return <Spinner /> // ❌ isLoading undefined in v5
// CORRECT - v5 syntax
const { isPending } = useQuery(...)
if (isPending) return <Spinner /> // ✅ Shows while query pendingisLoadingtrueisPendingtrueisPendingtrueisLoadingfalseisLoadingif (undefined)isLoading: undefinedisPendingkeepPreviousDataplaceholderData// WRONG - v4 syntax
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
keepPreviousData: true, // ❌ Removed in v5
})
// CORRECT - v5 syntax
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData, // ✅ Function form
})keepPreviousData: trueplaceholderDatatrue// WRONG - void return breaks v5
queryFn: async () => {
await api.deleteTodo(id) // ❌ Returns void
}
// CORRECT - return something
queryFn: async () => {
await api.deleteTodo(id)
return { success: true } // ✅ Return data
}Promise<TData>Promise<void>any// WRONG - creates infinite loop
useQuery({
queryKey: ['user', user], // ❌ Object in key
queryFn: () => fetchUser(user.id),
})
// WHY IT LOOPS:
// 1. Query runs, gets data
// 2. Component re-renders
// 3. New `user` object created (different reference)
// 4. Key changes → query refetches
// 5. Goto 1 (infinite)
// CORRECT - use primitive values
useQuery({
queryKey: ['user', user.id], // ✅ String is stable
queryFn: () => fetchUser(user.id),
})console.log// WRONG - data stuck in cache
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: Infinity, // ❌ Never marks stale
})
// User adds todo on another tab → never sees it
// CORRECT - reasonable staleTime
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // ✅ 5 minutes
})staleTime: 0staleTime: InfinitystaleTime: 5minstaleTime: InfinitystaleTime// WRONG - nukes entire cache
onSuccess: () => {
queryClient.invalidateQueries() // ❌ Refetches EVERYTHING
}
// User updates profile → todos, posts, comments all refetch
// CORRECT - targeted invalidation
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] }) // ✅ Only user
}Mutation completes...
│
├─ Simple list append/prepend → Optimistic (useMutationState)
│ └─ Add todo, add comment, add item
│ WHY: No complex logic, just show pending item
│
├─ Complex computed data → Invalidation
│ └─ Change affects aggregates, filters, sorts
│ WHY: Server computes, client doesn't duplicate logic
│
├─ Risk of conflicts → Invalidation
│ └─ Multiple users editing same data
│ WHY: Optimistic update may be wrong, let server resolve
│
└─ Must feel instant → Optimistic + rollback on error
└─ Toggle like, toggle favorite
WHY: User expects immediate feedbackChoose React Query when:
✅ Need fine-grained cache control (gc, stale times)
✅ Complex invalidation patterns
✅ Optimistic updates with rollback
✅ Infinite queries (pagination)
✅ Already using TanStack ecosystem (Table, Router)
Choose SWR when:
✅ Simpler API (less configuration)
✅ Automatic revalidation on focus is main use case
✅ Smaller bundle size priority
✅ Using Next.js (first-party support)// WRONG - server renders loading, client renders data
function Page() {
const { data, isPending } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <div>Loading...</div> // ❌ Mismatch
return <div>{data.map(...)}</div>
}
// SERVER: Renders "Loading..."
// CLIENT: Has cached data → renders list
// RESULT: Hydration error// app/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function Page() {
const queryClient = new QueryClient()
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList /> {/* Client Component */}
</HydrationBoundary>
)
}
// components/TodoList.tsx (Client Component)
'use client'
export function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// No isPending check - data guaranteed from server
return <div>{data.map(...)}</div>
}// Add to QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onSuccess: (data, query) => {
console.count(`Refetch: ${query.queryKey}`)
// If count > 10 in 1 second → infinite loop
}
}
}
})import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// Add to app
<ReactQueryDevtools initialIsOpen={false} />
// Click query → see refetch count, staleness, gc time// Force immediate stale (for testing)
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Check if query is stale
const state = queryClient.getQueryState(['todos'])
console.log(state?.isInvalidated) // true = will refetch on next mountconsole.log(JSON.stringify(data))dehydrate(queryClient)initialData// Server passes data via props
export default function Page() {
const data = await fetchTodos()
return <TodoList initialTodos={data} />
}
// Client receives and uses as initialData
function TodoList({ initialTodos }) {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos, // Hydrates without mismatch
})
}console.count(\structuralSharingconst stableKey = useMemo(() => ['user', user], [user.id])
useQuery({
queryKey: stableKey,
queryFn: () => fetchUser(user.id),
structuralSharing: false, // Prevents reference comparison issues
})staleTimestaleTime: InfinityqueryClient.invalidateQueries({ queryKey: ['your-key'] })queryClient.removeQueries({ queryKey: ['your-key'] })
queryClient.refetchQueries({ queryKey: ['your-key'] })queryClient.clear()strict: truetsconfig.jsongrep -r "cacheTime\|isLoading\|keepPreviousData" src/// In QueryClient setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// @ts-ignore - intentionally check for v4 props
...(process.env.NODE_ENV === 'development' && {
// Warn if someone passes v4 props
onError: (err, query) => {
if ('cacheTime' in query) console.warn('⚠️ cacheTime removed in v5, use gcTime')
if ('isLoading' in query) console.warn('⚠️ isLoading removed in v5, use isPending')
}
})
}
}
})references/v5-features.mdreferences/migration-guide.md