If useQuery and useMutation are the interface, then QueryClient is the brain that powers everything.
This guide covers every method, every property, when to use what, how it works, and what happens behind the scenes.
From @tanstack/react-query
📋 Table of Contents
- What is QueryClient?
- Architecture Overview
- Creating QueryClient
- Core Properties
- Query Methods (Read)
- Query Methods (Write)
- Query Methods (Control)
- Mutation Methods
- Global State Methods
- Internal Managers
- Advanced Concepts
- Real-World Patterns
- Performance Optimization
- Common Mistakes
⚡ Quick Summary
TL;DR:
QueryClientis the central orchestrator that:
- ✅ Manages all queries and mutations
- ✅ Controls cache storage and invalidation
- ✅ Handles retry logic and background refetch
- ✅ Manages observers and notifications
- ✅ Performs garbage collection
- ✅ Optimizes re-renders with structural sharing
Length: ~30 min read | Level: Intermediate to Advanced
📦 Installation
npm install @tanstack/react-query
🧠 What is QueryClient? {#what-is-queryclient}
Simple Definition:
QueryClient = Server State Manager + Cache Engine + Scheduler
What It Does:
- ✅ Stores cache - All query/mutation data
- ✅ Manages queries - Lifecycle, state, refetch
- ✅ Manages mutations - Execution, retry, queue
- ✅ Notifies observers - Component updates
- ✅ Handles garbage collection - Memory cleanup
- ✅ Controls refetch triggers - Focus, reconnect
- ✅ Manages retry + background sync - Network resilience
Key Insight:
QueryClient exists OUTSIDE React.
It's an independent state machine. React is just an observer.
This is why React Query is so efficient - no prop drilling, no context cascades, just fine-grained subscriptions.
🏗 Architecture Overview {#architecture-overview}
Internal Structure:
QueryClient
├── QueryCache (stores all queries)
│ ├── Query (state, observers, retryer)
│ ├── Query
│ └── Query
│
├── MutationCache (stores all mutations)
│ ├── Mutation
│ └── Mutation
│
└── Managers
├── FocusManager (window focus events)
├── OnlineManager (network events)
├── NotifyManager (batched updates)
└── GC System (cleanup)
Under the Hood:
class QueryClient {
queryCache: QueryCache // All Query instances
mutationCache: MutationCache // All Mutation instances
defaultOptions: DefaultOptions // Global config
// Plus internal managers
}
Each Query internally contains:
Query {
queryKey: ['users'],
state: {
data,
status,
error,
fetchStatus,
dataUpdatedAt
},
observers: [], // Connected components
retryer: Retryer, // Retry logic
promise: Promise // Current fetch
}
🚀 Creating QueryClient {#creating-queryclient}
Basic Creation:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
With Configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 0,
onError: (error) => {
console.error('Mutation error:', error)
}
}
}
})
Constructor Options:
| Option | Type | Description |
|---|---|---|
queryCache |
QueryCache | Custom QueryCache instance |
mutationCache |
MutationCache | Custom MutationCache instance |
defaultOptions |
Object | Global default settings |
Behind the scenes:
- Creates QueryCache and MutationCache instances
- Initializes internal managers (focus, online, notify)
- Deep merges default options with per-query options
🔑 Core Properties {#core-properties}
1. queryCache
The database of all Query instances.
const cache = queryClient.getQueryCache()
Type: Map<queryHash, Query>
Behind the scenes:
- Every unique
queryKeycreates a Query instance - Stored as a hash map for O(1) lookup
- Contains state, observers, retry logic
Use cases:
- Custom cache inspection
- Advanced debugging
- Building custom tools
2. mutationCache
Storage for all Mutation instances.
const mutationCache = queryClient.getMutationCache()
Type: Array<Mutation>
Behind the scenes:
- Tracks all mutations (past and present)
- Used for retry queue and offline support
3. defaultOptions
Global configuration that applies to all queries/mutations unless overridden.
const options = queryClient.getDefaultOptions()
Merge priority:
Per-query options > defaultOptions > Built-in defaults
📖 Query Methods (Read) {#query-methods-read}
1. getQueryData(queryKey)
Synchronously read data from cache.
const users = queryClient.getQueryData(['users'])
Type:
getQueryData<TData>(queryKey: QueryKey): TData | undefined
Behind the scenes:
- Hash the queryKey
- Lookup in QueryCache
- Return
state.data(or undefined) - No fetch triggered
- No observers notified
When to use:
- ✅ Read cached data in event handlers
- ✅ Check if data exists before fetching
- ✅ Implement custom logic based on cache state
Example - Dependent data:
function UserPosts({ userId }) {
// Check if user data is cached
const userData = queryClient.getQueryData(['user', userId])
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: fetchPosts,
enabled: !!userData // Only fetch if user exists
})
}
2. getQueriesData(filters)
Get data from multiple queries at once.
const allUserData = queryClient.getQueriesData({ queryKey: ['users'] })
// Returns: [[queryKey, data], [queryKey, data], ...]
Filters:
{
queryKey: ['users'], // Match prefix
exact: false, // Exact match vs prefix
type: 'active', // 'active' | 'inactive' | 'all'
stale: true, // Only stale queries
fetchStatus: 'idle', // 'idle' | 'fetching' | 'paused'
}
Behind the scenes:
- Iterates QueryCache
- Applies filters
- Returns matching [key, data] pairs
When to use:
- ✅ Bulk data reading
- ✅ Building admin tools
- ✅ Custom cache management
3. getQueryState(queryKey)
Get full state of a query (not just data).
const state = queryClient.getQueryState(['users'])
// Returns: { data, error, status, fetchStatus, ... }
Returns:
{
data,
dataUpdatedAt,
error,
errorUpdatedAt,
fetchFailureCount,
fetchFailureReason,
fetchStatus,
isInvalidated,
status
}
When to use:
- ✅ Check loading state outside components
- ✅ Inspect error details
- ✅ Debug query state
✏️ Query Methods (Write) {#query-methods-write}
1. setQueryData(queryKey, updater)
Manually update cache data.
// Direct value
queryClient.setQueryData(['users'], newUsers)
// Updater function
queryClient.setQueryData(['users'], (old) => [...old, newUser])
Type:
setQueryData<TData>(
queryKey: QueryKey,
updater: TData | ((old: TData | undefined) => TData)
): TData | undefined
Behind the scenes:
- Find/create Query instance
- Apply updater function
- Structural sharing - reuse unchanged references
- Update state
- Notify observers
- Trigger React re-renders
- No network request
When to use:
- ✅ Optimistic updates (in onMutate)
- ✅ Manual cache updates
- ✅ Denormalization after mutation
- ✅ Real-time updates (WebSocket)
Example - Optimistic update:
const addTodo = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistic update
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, variables, context) => {
// Rollback
queryClient.setQueryData(['todos'], context.previousTodos)
}
})
Example - Real-time update:
useEffect(() => {
const ws = new WebSocket('ws://api.example.com')
ws.onmessage = (event) => {
const newMessage = JSON.parse(event.data)
queryClient.setQueryData(['messages'], (old) => [...old, newMessage])
}
return () => ws.close()
}, [])
2. setQueriesData(filters, updater)
Batch update multiple queries.
queryClient.setQueriesData(
{ queryKey: ['users'] },
(old) => old.map(user => ({ ...user, online: true }))
)
Behind the scenes:
- Find all matching queries
- Apply updater to each
- Single batched notification
- Efficient re-renders
When to use:
- ✅ Global state updates (e.g., theme change)
- ✅ Bulk optimistic updates
- ✅ Normalizing related queries
🎮 Query Methods (Control) {#query-methods-control}
1. fetchQuery(options)
Programmatically fetch a query.
const users = await queryClient.fetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 10000
})
Behind the scenes:
- Check if Query exists → reuse it
- Check stale time
- If needed, start fetch via Retryer
- Return promise
- Always fetches (ignores cache unless staleTime not expired)
When to use:
- ✅ Imperative fetching in event handlers
- ✅ Server-side rendering
- ✅ Prefetching before navigation
Example - Button click:
const handleRefresh = async () => {
setLoading(true)
try {
const data = await queryClient.fetchQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard
})
console.log('Fresh data:', data)
} finally {
setLoading(false)
}
}
2. prefetchQuery(options)
Fetch data without returning it (fire-and-forget).
queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
Difference from fetchQuery:
| Feature | fetchQuery | prefetchQuery |
|---------|-----------|---------------|
| Returns data | ✅ Yes | ❌ No (void) |
| Throws errors | ✅ Yes | ❌ No (silently fails) |
| Use case | Immediate use | Background prefetch |
When to use:
- ✅ Route-level prefetching
- ✅ Hover prefetch
- ✅ Anticipated user actions
Example - Route prefetch:
const router = createBrowserRouter([
{
path: '/users',
loader: async () => {
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
return null
},
element: <Users />
}
])
Example - Hover prefetch:
<Link
to="/user/123"
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ['user', 123],
queryFn: () => fetchUser(123)
})
}}
>
View User
</Link>
3. ensureQueryData(options)
Guarantee data exists in cache.
const users = await queryClient.ensureQueryData({
queryKey: ['users'],
queryFn: fetchUsers
})
Behind the scenes:
- Check cache
- If data exists → return it
- If missing → fetch and return
- Idempotent - safe to call multiple times
When to use:
- ✅ Ensuring data before rendering
- ✅ Defensive programming
- ✅ SSR with hydration
Example:
async function loader() {
// Ensures data is ready before route renders
const userData = await queryClient.ensureQueryData({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
return { userData }
}
4. invalidateQueries(filters)
Mark queries as stale (they'll refetch if active).
queryClient.invalidateQueries({ queryKey: ['users'] })
Behind the scenes:
- Find all matching queries
- Set
isInvalidated = true - If query has active observers → schedule refetch
- If no active observers → just mark stale (refetch on next mount)
Important:
Invalidation ≠ immediate refetch
It marks stale first. Refetch happens only if query is being observed.
Filters:
{
queryKey: ['users'], // Match queries
exact: false, // Exact match
type: 'active', // Only active queries
refetchType: 'active', // Control refetch behavior
}
When to use:
- ✅ After mutations (most common)
- ✅ After external updates
- ✅ Periodic invalidation
Example - After mutation:
const createUser = useMutation({
mutationFn: createUserAPI,
onSuccess: () => {
// Invalidate all queries with 'users' key
queryClient.invalidateQueries({ queryKey: ['users'] })
// Also invalidate related data
queryClient.invalidateQueries({ queryKey: ['stats'] })
}
})
Example - Prefix matching:
// Invalidate all user-related queries
queryClient.invalidateQueries({ queryKey: ['users'] })
// Matches:
// ['users']
// ['users', 1]
// ['users', 1, 'posts']
// etc.
5. refetchQueries(filters)
Force refetch queries immediately.
await queryClient.refetchQueries({ queryKey: ['users'] })
Difference from invalidateQueries:
| Feature | invalidateQueries | refetchQueries |
|---|---|---|
| Marks stale | ✅ Yes | ✅ Yes |
| Refetches inactive | ❌ No | ✅ Yes (optional) |
| Returns promise | ❌ No | ✅ Yes |
| Use case | After mutations | Manual refresh |
Options:
{
queryKey: ['users'],
type: 'active', // 'active' | 'inactive' | 'all'
exact: false,
stale: true // Only refetch if stale
}
When to use:
- ✅ Manual refresh button
- ✅ Force update after external event
- ✅ Revalidate after error
Example - Refresh button:
const handleRefresh = async () => {
setRefreshing(true)
await queryClient.refetchQueries({
queryKey: ['dashboard'],
type: 'active'
})
setRefreshing(false)
toast.success('Refreshed!')
}
6. cancelQueries(filters)
Cancel ongoing fetches.
await queryClient.cancelQueries({ queryKey: ['users'] })
Behind the scenes:
- Find all matching queries
- Call
retryer.cancel() - If fetch uses AbortController → trigger abort
- Promise rejects with CancelledError
When to use:
- ✅ Before optimistic updates (prevent race conditions)
- ✅ Component unmount cleanup
- ✅ User-initiated cancellation
Example - Race condition prevention:
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
// Cancel to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['user', userId] })
const previous = queryClient.getQueryData(['user', userId])
queryClient.setQueryData(['user', userId], newData)
return { previous }
}
})
7. removeQueries(filters)
Completely remove queries from cache.
queryClient.removeQueries({ queryKey: ['users'] })
Behind the scenes:
- Find matching queries
- Detach all observers
- Delete from QueryCache
- Free memory
- Data is gone - not just marked stale
When to use:
- ✅ Logout (clear user data)
- ✅ Memory management
- ✅ Testing cleanup
Example - Logout:
const handleLogout = () => {
// Clear all user-specific data
queryClient.removeQueries({ queryKey: ['user'] })
queryClient.removeQueries({ queryKey: ['private'] })
navigate('/login')
}
8. resetQueries(filters)
Reset queries to initial state.
queryClient.resetQueries({ queryKey: ['users'] })
Behind the scenes:
- Reset state to initial values
- Trigger refetch if active
- Like "starting fresh"
When to use:
- ✅ Clear errors
- ✅ Reset to default state
- ✅ Testing
🔄 Mutation Methods {#mutation-methods}
1. getMutationCache()
Access the MutationCache.
const mutationCache = queryClient.getMutationCache()
const mutations = mutationCache.getAll()
When to use:
- ✅ Track mutation history
- ✅ Build undo/redo
- ✅ Debugging
2. isMutating(filters?)
Count active mutations.
const mutatingCount = queryClient.isMutating()
// With filters
const userMutations = queryClient.isMutating({
mutationKey: ['updateUser']
})
Behind the scenes:
- Iterates MutationCache
- Counts mutations with
status === 'pending'
When to use:
- ✅ Global loading indicator
- ✅ Disable actions during mutations
- ✅ UX feedback
Example - Global loader:
function GlobalLoader() {
const isMutating = queryClient.isMutating()
const isFetching = queryClient.isFetching()
const isLoading = isMutating > 0 || isFetching > 0
return isLoading ? <Spinner /> : null
}
3. resumePausedMutations()
Resume offline-queued mutations.
await queryClient.resumePausedMutations()
Behind the scenes:
- Find paused mutations
- Check network status
- Resume retryer
- Execute queue
When to use:
- ✅ Offline-first apps
- ✅ After network reconnects
- ✅ Manual retry
🌐 Global State Methods {#global-state-methods}
1. isFetching(filters?)
Count active fetches.
const fetchingCount = queryClient.isFetching()
// With filters
const userFetches = queryClient.isFetching({ queryKey: ['users'] })
Behind the scenes:
- Iterates QueryCache
- Counts queries with
fetchStatus === 'fetching'
When to use:
- ✅ Global loading spinner
- ✅ Network activity indicator
- ✅ Disable actions during fetch
Example:
function App() {
const isFetching = queryClient.isFetching()
return (
<>
{isFetching > 0 && <TopProgressBar />}
<Routes />
</>
)
}
2. clear()
Wipe everything - queries and mutations.
queryClient.clear()
Behind the scenes:
- Clear QueryCache
- Clear MutationCache
- Detach all observers
- Reset to pristine state
When to use:
- ✅ Testing cleanup
- ✅ Hard reset
- ❌ Avoid in production (use removeQueries instead)
3. getDefaultOptions()
Get global default options.
const defaults = queryClient.getDefaultOptions()
4. setDefaultOptions(options)
Update global defaults.
queryClient.setDefaultOptions({
queries: {
staleTime: 30000
}
})
When to use:
- ✅ Dynamic configuration
- ✅ A/B testing
- ✅ Feature flags
🛠 Internal Managers {#internal-managers}
These are the hidden systems that make React Query work.
1. FocusManager 👁
Listens to window focus events.
window.addEventListener('visibilitychange')
window.addEventListener('focus')
What it does:
- User switches back to tab → trigger refetch for stale queries
Controlled by:
refetchOnWindowFocus: true // Query option
Behind the scenes:
- Focus event fires
- FocusManager notifies QueryClient
- QueryClient checks all active queries
- Stale queries refetch
2. OnlineManager 🌐
Listens to network events.
window.addEventListener('online')
window.addEventListener('offline')
What it does:
- Network reconnects → resume paused queries/mutations
Controlled by:
refetchOnReconnect: true
networkMode: 'online'
Behind the scenes:
- Online event fires
- OnlineManager notifies QueryClient
- Paused retryers resume
- Queued mutations execute
3. NotifyManager 🔔
Batches state updates.
What it does:
- Multiple state changes → single batched render
- Integrates with React's batching
Behind the scenes:
- Observer state changes
- NotifyManager collects notifications
- React batching applies
- Single re-render
This is why React Query is performant - intelligent batching prevents unnecessary renders.
4. GC System 🗑
Garbage collection for inactive queries.
What it does:
- Query becomes inactive (no observers)
- GC timer starts (
gcTime) - Timer expires → query removed from cache
Controlled by:
gcTime: 5 * 60 * 1000 // 5 minutes (default)
Behind the scenes:
- Uses
setTimeout - Cleanup on timer expiry
- Prevents memory leaks
🧠 Advanced Concepts {#advanced-concepts}
1. Structural Sharing 🧬
Reuses unchanged object references.
What it does:
const oldData = { users: [{ id: 1, name: 'John' }] }
const newData = { users: [{ id: 1, name: 'John' }] }
// Without structural sharing:
oldData !== newData // true → React re-renders
// With structural sharing:
// Query Client reuses old reference if data is equal
// → React skips re-render
Behind the scenes:
- New data arrives
- Deep comparison with old data
- Unchanged branches → reuse old references
- Changed branches → use new references
- React only re-renders if reference changed
Why it matters:
- Prevents unnecessary re-renders
- Especially important for large data structures
2. Observer System 👀
Components don't directly subscribe to QueryClient.
How it works:
Component → useQuery → QueryObserver → Query → QueryClient
- Component calls
useQuery - Creates QueryObserver
- Observer subscribes to Query
- Query state changes
- Observer notifies component
- Component re-renders
Smart updates:
- Observer tracks which properties component accessed
- Only notifies if those properties changed
- This is why
notifyOnChangePropsworks
3. Retryer System 🔁
Handles all retry logic.
Each Query/Mutation has a Retryer instance.
What it does:
- Execute fetch function
- If fails → check retry logic
- Wait (exponential backoff)
- Retry
- Repeat until success or max retries
Exponential backoff:
delay = Math.min(1000 * 2 ** attemptIndex, 30000)
// Attempt 0: 1s
// Attempt 1: 2s
// Attempt 2: 4s
// Attempt 3: 8s
// etc., capped at 30s
Can be cancelled:
retryer.cancel() // Aborts current fetch
4. Query Hash System 🔑
QueryKeys are converted to stable hashes.
Why:
- Keys can be arrays with objects
- Need stable identifier for Map lookup
Behind the scenes:
queryKey: ['users', { status: 'active' }]
↓
queryHash: '["users",{"status":"active"}]'
↓
Used for cache lookup
This enables:
- Fast O(1) cache lookups
- Stable reference for same keys
🎯 Real-World Patterns {#real-world-patterns}
1. Prefetch on Route Change
const routes = [
{
path: '/users',
loader: async () => {
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
return null
},
element: <Users />
}
]
2. Optimistic Updates with Rollback
const updateTodo = useMutation({
mutationFn: updateTodoAPI,
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
)
return { previousTodos }
},
onError: (err, updatedTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
3. Global Loading Indicator
function GlobalLoader() {
const isFetching = queryClient.isFetching()
const isMutating = queryClient.isMutating()
const isLoading = isFetching > 0 || isMutating > 0
return (
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<TopProgressBar />
</motion.div>
)}
</AnimatePresence>
)
}
4. Logout Cleanup
const logout = useMutation({
mutationFn: logoutAPI,
onSuccess: () => {
// Remove all user data
queryClient.removeQueries({
predicate: (query) =>
query.queryKey[0] === 'user' ||
query.queryKey[0] === 'private'
})
navigate('/login')
}
})
5. Real-time Updates via WebSocket
useEffect(() => {
const ws = new WebSocket('ws://api.example.com')
ws.onmessage = (event) => {
const update = JSON.parse(event.data)
// Update cache
queryClient.setQueryData(
['item', update.id],
update.data
)
// Or invalidate
queryClient.invalidateQueries({
queryKey: ['items']
})
}
return () => ws.close()
}, [])
6. Pagination Helper
const prefetchNextPage = (currentPage) => {
queryClient.prefetchQuery({
queryKey: ['users', currentPage + 1],
queryFn: () => fetchUsers(currentPage + 1)
})
}
function UserList({ page }) {
const { data } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page)
})
// Prefetch next page
useEffect(() => {
prefetchNextPage(page)
}, [page])
return <div>...</div>
}
7. Custom Cache Persistence
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const persister = createSyncStoragePersister({
storage: window.localStorage
})
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
})
⚡ Performance Optimization {#performance-optimization}
1. Use Appropriate Stale Times
// Fast-changing data
queryClient.setQueryDefaults(['live-scores'], {
staleTime: 0
})
// Slow-changing data
queryClient.setQueryDefaults(['user-profile'], {
staleTime: 60 * 60 * 1000 // 1 hour
})
// Static data
queryClient.setQueryDefaults(['countries'], {
staleTime: Infinity
})
2. Selective Invalidation
// ❌ BAD - Invalidates everything
queryClient.invalidateQueries()
// ✅ GOOD - Targeted invalidation
queryClient.invalidateQueries({
queryKey: ['users', userId]
})
3. Batch Updates
// ❌ BAD - Multiple separate updates
users.forEach(user => {
queryClient.setQueryData(['user', user.id], user)
})
// ✅ GOOD - Single batched update
queryClient.setQueriesData(
{ queryKey: ['user'] },
(old) => updateUserLogic(old)
)
4. Optimize Re-renders
// Only re-render when data changes
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.users,
notifyOnChangeProps: ['data']
})
⚠️ Common Mistakes {#common-mistakes}
❌ 1. Not Sharing QueryClient
// BAD - Creates new instance per component
function MyComponent() {
const queryClient = new QueryClient() // ❌
return <QueryClientProvider client={queryClient}>...</>
}
// GOOD - Single instance for entire app
const queryClient = new QueryClient()
function App() {
return <QueryClientProvider client={queryClient}>...</>
}
❌ 2. Clearing Cache Too Aggressively
// BAD - Loses all data
queryClient.clear()
// GOOD - Remove specific data
queryClient.removeQueries({ queryKey: ['user'] })
❌ 3. Not Canceling Before Optimistic Updates
// BAD - Race condition possible
onMutate: (newData) => {
queryClient.setQueryData(['users'], newData) // ⚠️
}
// GOOD
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['users'] }) // ✅
queryClient.setQueryData(['users'], newData)
}
❌ 4. Using getQueryData in Render
// BAD - Not reactive
function MyComponent() {
const data = queryClient.getQueryData(['users']) // ⚠️
return <div>{data}</div>
}
// GOOD - Use useQuery
function MyComponent() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
return <div>{data}</div>
}
❌ 5. Forgetting to Invalidate
// BAD
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
toast.success('Created!')
// Missing: invalidateQueries ⚠️
}
})
// GOOD
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }) // ✅
toast.success('Created!')
}
})
🎯 Key Takeaways
QueryClient is:
- ✅ Central orchestrator - Controls all queries/mutations
- ✅ Cache database - Stores all server state
- ✅ Scheduler - Manages refetch timing
- ✅ Observer manager - Notifies components
- ✅ Retry engine - Handles failures
- ✅ GC system - Prevents memory leaks
- ✅ Optimization layer - Structural sharing, batching
Important Concepts:
- Exists outside React - Independent state machine
- Fine-grained subscriptions - No prop drilling needed
- Structural sharing - Prevents unnecessary renders
- Smart invalidation - Mark stale, refetch only if active
- Intelligent caching - Automatic lifecycle management
📌 Final Thought
QueryClient is the brain that makes React Query powerful.
Understanding it deeply helps you:
- ✅ Debug effectively
- ✅ Optimize performance
- ✅ Build advanced patterns
- ✅ Handle edge cases
- ✅ Interview confidently
The more you understand QueryClient, the more control you have over your app's server state.
🚀 Next Steps
- Read TanStack Query docs
- Explore QueryCache and MutationCache APIs
- Study Retryer implementation
- Build custom DevTools
- Contribute to React Query
💡 Pro Tips
Start with these methods:
-
invalidateQueries- After mutations -
setQueryData- Optimistic updates -
prefetchQuery- Route prefetching -
getQueryData- Read cache
Master these concepts:
- Observer system
- Structural sharing
- Retryer logic
- GC behavior
🤝 Let's Connect
Found this helpful?
- ❤️ Save for future reference
- 🔄 Share with your team
- 💬 Ask questions in comments
- 🐦 Follow for more React content
What QueryClient feature confused you most? Comment below! 👇
📚 Official Docs:
https://tanstack.com/query/latest/docs/react/overview
Happy querying! 🎯
Tags: #react #tanstack #reactquery #javascript #webdev #frontend #queryclient #statemanagement
Top comments (0)