The biggest challenge in modern React apps:
- ✅ Data fetching
- ✅ Caching
- ✅ Background sync
- ✅ Retry logic
- ✅ Loading/error state handling
- ✅ Avoiding unnecessary re-renders
@tanstack/react-query's useQuery provides a production-grade solution for all of these.
This article is for developers from beginner to advanced — especially if you want to understand performance and internals.
📋 Table of Contents
- Installation & Setup
- Why useQuery?
- Basic Usage
- Complete API Reference
- Core States
- Critical Options Explained
- Return Values Explained
- Real-World Patterns
- Performance Optimization
- Common Mistakes
- Internal Architecture
⚡ Quick Summary
TL;DR:
useQueryis your production-ready solution for:
- ✅ Automatic caching & background sync
- ✅ Loading/error states out of the box
- ✅ Smart refetch strategies (window focus, network reconnect)
- ✅ Retry logic with exponential backoff
- ✅ Optimized re-renders (structural sharing)
- ✅ Zero boilerplate for common patterns
Article Length: ~20 min read | Skill Level: Beginner to Advanced
📦 Installation & Setup
# npm
npm install @tanstack/react-query
# yarn
yarn add @tanstack/react-query
# pnpm
pnpm add @tanstack/react-query
Basic Setup:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
🔧 Pro Tip: Install React Query DevTools for debugging:
npm install @tanstack/react-query-devtools
🔗 GitHub Repository:
https://github.com/TanStack/query
🧠 Why useQuery? {#why-usequery}
Traditional Way:
useEffect(() => {
setLoading(true)
fetch('/api')
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [])
❌ Problems:
- No caching
- No retry
- No background refetch
- No stale handling
- Manual loading state
- Hard to scale
useQuery makes server state lifecycle-aware.
💡 Key Insight: React Query isn't just a data fetching library - it's a complete async state manager specifically designed for server state.
🚀 Basic Usage {#basic-usage}
import { useQuery } from '@tanstack/react-query'
function Users() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return <UserList data={data} />
}
🔑 queryKey
- Unique identity for the query
- Cache is stored based on this key
- Key change triggers a refetch
- Must be serializable and stable
// ✅ Good - Stable keys
['users']
['user', userId]
['posts', { status: 'published', page: 1 }]
// ❌ Bad - Unstable (new reference every render)
[{ id: userId }]
📦 Complete API Reference {#complete-api-reference}
🎯 All Options (Configuration)
const result = useQuery(
{
queryKey, // Required
queryFn, // Required
gcTime,
enabled,
networkMode,
initialData,
initialDataUpdatedAt,
meta,
notifyOnChangeProps,
placeholderData,
queryKeyHashFn,
refetchInterval,
refetchIntervalInBackground,
refetchOnMount,
refetchOnReconnect,
refetchOnWindowFocus,
retry,
retryOnMount,
retryDelay,
select,
staleTime,
structuralSharing,
subscribed,
throwOnError,
},
queryClient,
)
🎁 All Return Values
const {
data, // Query result data
dataUpdatedAt, // Last update timestamp
error, // Error object
errorUpdatedAt, // Last error timestamp
failureCount, // Failed attempts count
failureReason, // Last failure reason
fetchStatus, // 'fetching' | 'paused' | 'idle'
isError, // Error state boolean
isFetched, // At least one fetch completed
isFetchedAfterMount, // Fetched after component mount
isFetching, // Currently fetching (incl. bg)
isInitialLoading, // First fetch in progress
isLoading, // Legacy: same as isPending
isLoadingError, // Error on initial load
isPaused, // Network paused
isPending, // No data yet
isPlaceholderData, // Showing placeholder
isRefetchError, // Error on refetch
isRefetching, // Background refetch
isStale, // Data is stale
isSuccess, // Has successful data
isEnabled, // Query is enabled
promise, // Promise for data
refetch, // Manual refetch function
status, // 'pending' | 'error' | 'success'
} = useQuery(...)
🔵 Core States You Must Understand {#core-states}
1️⃣ isPending (formerly isLoading)
// First fetch is happening, no data available yet
if (isPending) return <Spinner />
2️⃣ isFetching
// Any fetch (including background refetch)
{isFetching && <LoadingIndicator />}
3️⃣ isSuccess
// Data successfully available
if (isSuccess) return <UserList data={data} />
4️⃣ isError
// Query failed
if (isError) return <ErrorMessage error={error} />
🎯 Key Difference:
| State | isPending | isFetching | Meaning |
|---|---|---|---|
| Initial Load | ✅ true | ✅ true | First fetch in progress |
| Background Refetch | ❌ false | ✅ true | Data exists, updating |
| Success (no fetch) | ❌ false | ❌ false | Data available and fresh |
| Paused | ✅ true | ❌ false | Network offline |
// Pattern for loading states
if (isPending) return <FullPageLoader /> // No data yet
if (isFetching) return <InlineRefreshIcon /> // Updating existing data
🔄 Critical Options Explained {#critical-options-explained}
1. queryKey (Required)
// ✅ Static
queryKey: ['users']
// ✅ Dynamic with params
queryKey: ['users', userId, { status: 'active' }]
// ❌ Unstable (re-creates on every render)
queryKey: [{ id: userId }] // New object every time!
Rule: QueryKey must be stable and serializable.
2. queryFn (Required)
// Simple
queryFn: () => fetch('/api/users').then(r => r.json())
// With abort signal
queryFn: ({ signal }) =>
fetch('/api/users', { signal }).then(r => r.json())
// With query key params
queryFn: ({ queryKey }) => {
const [_key, userId] = queryKey
return fetchUser(userId)
}
Must return: Promise or throw error.
3. staleTime ⏱
// How long data is considered fresh
staleTime: 60 * 1000 // 1 minute
// Fresh data = no refetch on mount/focus
// Stale data = refetch triggered
Default: 0 (immediately stale)
Use cases:
- Static data:
staleTime: Infinity - Slow-changing:
staleTime: 5 * 60 * 1000 - Real-time:
staleTime: 0
4. gcTime (formerly cacheTime) 🗑
// How long unused query stays in cache
gcTime: 5 * 60 * 1000 // 5 minutes
Default: 5 minutes
Flow:
- Component unmount → query becomes inactive
- After
gcTime→ cache cleared - Next mount → fresh fetch
5. enabled 🎚
// Conditional fetching
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
enabled: !!userId // Only fetch when userId exists
})
// Dependent queries
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: fetchUserPosts,
enabled: !!user?.id
})
6. select 🎯
// Data transform (with memoization)
const { data: userNames } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.map(user => user.name)
})
Benefits:
- Transforms data
- Memoized by React Query
- Component only re-renders when selected data changes
7. refetchInterval 🔁
// Polling
refetchInterval: 5000 // Every 5 seconds
// Conditional polling
refetchInterval: (data) =>
data?.status === 'processing' ? 2000 : false
// Smart: Stop polling when done
Note: Only active when window is focused (unless refetchIntervalInBackground: true)
8. refetchOnWindowFocus 👁
// Default: true
refetchOnWindowFocus: true
// User switches back to tab → refetch if stale
Best practice:
- Enable for dashboards
- Disable for heavy/expensive queries
9. refetchOnMount 🔄
// Default: true
refetchOnMount: true // Refetch on mount if stale
refetchOnMount: 'always' // Always refetch
refetchOnMount: false // Never refetch on mount
10. refetchOnReconnect 📡
// Default: true
// Refetch when network reconnects
refetchOnReconnect: true
11. retry 🔁
// Retry count
retry: 3 // Default
// Custom retry logic
retry: (failureCount, error) => {
if (error.status === 404) return false
return failureCount < 3
}
12. retryDelay ⏱
// Exponential backoff (default)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
// Fixed delay
retryDelay: 1000
13. structuralSharing 🧬
// Default: true
structuralSharing: true
// Deep comparison to preserve references
// Avoids unnecessary re-renders
How it works:
// API returns new object
const newData = { users: [...] }
// React Query checks structure
// If content same → reuses old reference
// React doesn't re-render
Disable when:
- Huge deeply nested data
- Performance cost > benefit
14. notifyOnChangeProps 🎯
// Fine-grained render control
notifyOnChangeProps: ['data']
// Component only re-renders when data changes
// Ignores isLoading, isFetching, etc.
Use carefully: Can cause stale UI if misused.
15. placeholderData 📋
// Show temporary data while loading
placeholderData: previousData => previousData
// Or static
placeholderData: []
16. initialData 💾
// Prefilled data (treated as fresh)
initialData: () => getCachedUser()
initialDataUpdatedAt: Date.now() // When data was fetched
Difference from placeholder:
-
initialData→ treated as real data -
placeholderData→ temporary, marked as placeholder
17. networkMode 🌐
// How to handle network status
networkMode: 'online' // Only fetch when online (default)
networkMode: 'always' // Fetch regardless
networkMode: 'offlineFirst' // Try cache first
18. throwOnError ⚠️
// Throw errors instead of returning them
throwOnError: true
// Use with Error Boundaries
19. meta 🏷
// Custom metadata
meta: {
errorMessage: 'Failed to load users',
trackingId: 'users-query'
}
// Accessible in queryClient callbacks
🎁 Return Values Explained {#return-values-explained}
Core Data Properties
data
const { data } = useQuery(...)
// Actual query result
// undefined until successful fetch
dataUpdatedAt
const { dataUpdatedAt } = useQuery(...)
// Timestamp (ms) of last successful fetch
// Useful for "Last updated" displays
Error Properties
error
const { error } = useQuery(...)
// Error object from failed queryFn
// null if no error
errorUpdatedAt
const { errorUpdatedAt } = useQuery(...)
// Timestamp of last error
failureCount
const { failureCount } = useQuery(...)
// Number of consecutive failures
// Resets on successful fetch
failureReason
const { failureReason } = useQuery(...)
// Last error that caused failure
Status Booleans
isPending vs isLoading
// isPending: Modern API (v5+)
// isLoading: Legacy (same as isPending)
if (isPending) return <Spinner />
isFetching vs isRefetching
// isFetching: ANY fetch (initial + background)
// isRefetching: Only background fetches
{isFetching && <GlobalLoader />}
{isRefetching && <RefreshIcon spin />}
isInitialLoading
// True only during first fetch
const { isInitialLoading } = useQuery(...)
// Equivalent to: isPending && isFetching
isFetched vs isFetchedAfterMount
// isFetched: At least one successful fetch (lifetime)
// isFetchedAfterMount: Fetched since component mounted
const { isFetched, isFetchedAfterMount } = useQuery(...)
isStale
// Data older than staleTime
const { isStale } = useQuery(...)
// true = eligible for refetch
isPlaceholderData
// Showing placeholder data
const { isPlaceholderData } = useQuery({
placeholderData: []
})
isLoadingError vs isRefetchError
// isLoadingError: Error on initial fetch
// isRefetchError: Error on background refetch
if (isLoadingError) return <FullPageError />
if (isRefetchError) return <ToastError />
Fetch Status
fetchStatus
const { fetchStatus } = useQuery(...)
// 'fetching' → actively fetching
// 'paused' → network offline
// 'idle' → not fetching
Control Properties
refetch
const { refetch } = useQuery(...)
// Manual refetch
<button onClick={() => refetch()}>
Refresh
</button>
// Returns promise
const { data } = await refetch()
promise
const { promise } = useQuery(...)
// Promise for current fetch
await promise // Wait for data
🔥 Real-World Production Patterns {#real-world-production-patterns}
✅ Dashboard
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
staleTime: 5 * 60 * 1000, // 5 min fresh
refetchInterval: 30 * 1000, // Poll every 30s
refetchOnWindowFocus: true, // Sync on focus
})
✅ Infinite Scroll List
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 60 * 1000,
select: (data) => ({
pages: data.pages.flatMap(page => page.items),
pageParams: data.pageParams,
}),
})
✅ Dependent Queries
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
enabled: !!userId,
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user.id),
enabled: !!user?.id,
staleTime: 2 * 60 * 1000,
})
✅ Optimistic Updates
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries(['user', id])
const previous = queryClient.getQueryData(['user', id])
queryClient.setQueryData(['user', id], newUser)
return { previous }
},
onError: (err, newUser, context) => {
queryClient.setQueryData(['user', id], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries(['user', id])
},
})
🧬 Structural Sharing (Render Optimization)
This is a hidden performance weapon.
Problem:
// Every API response = new object reference
const data = await fetch('/api')
// React thinks it's different → re-render
Solution:
React Query deep compares. If structure same → old reference reused → React skips re-render!
📊 Visual Example:
// First fetch
const data1 = { users: [{ id: 1, name: 'John' }] }
// Second fetch (same data, different object)
const data2 = { users: [{ id: 1, name: 'John' }] }
// Without structural sharing:
data1 === data2 // ❌ false → Component re-renders
// With structural sharing:
// React Query reuses data1 reference
// ✅ Component doesn't re-render!
🎯 When to Disable:
{
structuralSharing: false,
// Disable when:
// ❌ Huge deeply nested data (deep compare expensive)
// ❌ Already normalized state (Redux-like structure)
// ❌ Performance cost > benefit
}
⚡ Performance Impact: For large lists (1000+ items), structural sharing can save 50-70% unnecessary re-renders!
⚠️ Common Mistakes {#common-mistakes}
❌ Unstable queryKey
// BAD: New object every render
queryKey: [{ userId: 1 }]
// GOOD: Serializable values
queryKey: ['user', 1]
❌ staleTime: 0 everywhere
// Causes excessive refetches
// Set appropriate staleTime based on data freshness needs
❌ Polling without condition
// BAD: Always polling
refetchInterval: 5000
// GOOD: Conditional polling
refetchInterval: (data) =>
data?.status === 'processing' ? 5000 : false
❌ Overusing refetchOnWindowFocus
// Heavy queries should disable this
refetchOnWindowFocus: false
❌ Not using select for transformations
// BAD: Transform in component
const userNames = data?.map(u => u.name)
// GOOD: Use select
select: (data) => data.map(u => u.name)
🎯 When NOT to Use useQuery
❌ Pure client state
// Use useState/useReducer
const [count, setCount] = useState(0)
❌ Form input local state
// Use form libraries or useState
const [email, setEmail] = useState('')
❌ Animation state
// Use CSS or animation libraries
✅ Use useQuery for:
- Server state
- API data
- Cached remote data
- Background-synced data
🏗 Internal Architecture (High-Level) {#internal-architecture}
Under the hood:
QueryClient
├─ QueryCache (stores all queries)
│ └─ Query (individual query)
│ ├─ State (data, status, etc)
│ └─ Observers (subscribers)
│
└─ MutationCache
Flow:
- Component subscribes to query via QueryObserver
- Cache check happens
- Stale check happens
- Fetch triggered if needed
- Observer diff happens
- Only necessary re-render happens
This is why React Query works efficiently with React Fiber.
🔍 Advanced: Query Lifecycle
┌─────────────┐
│ Mount │
└──────┬──────┘
│
▼
┌─────────────┐ No Cache
│ Check Cache │────────────────┐
└──────┬──────┘ │
│ Has Cache │
▼ ▼
┌─────────────┐ ┌──────────┐
│Check Stale │ │ Fetch │
└──────┬──────┘ └────┬─────┘
│ │
Stale│ Fresh │
│ │ │
▼ ▼ ▼
┌────────────────────────────┐
│ Return Data to Component │
└────────────────────────────┘
│
▼
┌────────────────┐
│ Background │
│ Refetch │
│ (if conditions │
│ met) │
└────────────────┘
🎯 Understanding the Flow:
- Component mounts → Query observer created
- Cache checked → Return if fresh
- Stale check → Fetch if needed
- Data rendered → Component subscribes
- Background refetch (based on triggers)
- Only changed state → Re-render
🎯 Performance Checklist {#performance-checklist}
✅ Set meaningful staleTime
staleTime: 5 * 60 * 1000 // 5 minutes for slow-changing data
✅ Use select for transformations
select: (data) => data.users // Only re-render when users change
✅ Use notifyOnChangeProps for large states
notifyOnChangeProps: ['data'] // Only notify on data change
✅ Enable structuralSharing (default)
structuralSharing: true // Reuse references
✅ Conditional polling
refetchInterval: (data) => data?.needsUpdate ? 5000 : false
✅ Disable unnecessary refetch options
refetchOnWindowFocus: false, // For heavy queries
refetchOnMount: false, // If data won't change
🔥 Key Takeaways
useQuery isn't just a data fetch hook. It is a:
- Cache manager (gcTime, staleTime)
- Background sync engine (refetchOnWindowFocus, refetchInterval)
- Retry system (retry, retryDelay)
- Stale controller (staleTime, isStale)
- Render optimizer (structuralSharing, notifyOnChangeProps)
If you're building a production React app — understanding useQuery is mandatory.
📌 Final Thought
React Query's real power shows when you combine:
staleTime- Background refetch
- Structural sharing
- Observer-based rendering
-
selecttransformations
Intelligently managing server state is the next step in modern React architecture.
🚀 Next Steps
- Read TanStack Query docs
- Try
useMutationfor mutations - Explore
useInfiniteQueryfor pagination - Setup React Query DevTools
- Learn query invalidation patterns
💡 Pro Tips
💡 Start with these three options first:
staleTime– Control freshnessselect– Optimize re-rendersenabled– Conditional fetchingMaster these before diving into advanced patterns.
🤝 Let's Connect
Found this helpful?
- ❤️ Save this article for future reference
- 🔄 Share with your team
- 💬 Drop your questions in comments
- 🐦 Follow me for more React content
What's your biggest challenge with React Query? Comment below! 👇
📚 Official Docs:
https://tanstack.com/query/latest/docs/react/overview
Happy querying! 🎯
Tags: #react #tanstack #reactquery #javascript #webdev #frontend #performance #statemanagement
Top comments (0)