DEV Community

Munna Thakur
Munna Thakur

Posted on

Mastering QueryClient — The Brain Behind React Query (Complete Guide)

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


⚡ Quick Summary

TL;DR: QueryClient is 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
Enter fullscreen mode Exit fullscreen mode

🧠 What is QueryClient? {#what-is-queryclient}

Simple Definition:

QueryClient = Server State Manager + Cache Engine + Scheduler

What It Does:

  1. Stores cache - All query/mutation data
  2. Manages queries - Lifecycle, state, refetch
  3. Manages mutations - Execution, retry, queue
  4. Notifies observers - Component updates
  5. Handles garbage collection - Memory cleanup
  6. Controls refetch triggers - Focus, reconnect
  7. 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)
Enter fullscreen mode Exit fullscreen mode

Under the Hood:

class QueryClient {
  queryCache: QueryCache           // All Query instances
  mutationCache: MutationCache     // All Mutation instances
  defaultOptions: DefaultOptions   // Global config

  // Plus internal managers
}
Enter fullscreen mode Exit fullscreen mode

Each Query internally contains:

Query {
  queryKey: ['users'],
  state: {
    data,
    status,
    error,
    fetchStatus,
    dataUpdatedAt
  },
  observers: [],      // Connected components
  retryer: Retryer,   // Retry logic
  promise: Promise    // Current fetch
}
Enter fullscreen mode Exit fullscreen mode

🚀 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Type: Map<queryHash, Query>

Behind the scenes:

  • Every unique queryKey creates 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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Merge priority:

Per-query options > defaultOptions > Built-in defaults
Enter fullscreen mode Exit fullscreen mode

📖 Query Methods (Read) {#query-methods-read}

1. getQueryData(queryKey)

Synchronously read data from cache.

const users = queryClient.getQueryData(['users'])
Enter fullscreen mode Exit fullscreen mode

Type:

getQueryData<TData>(queryKey: QueryKey): TData | undefined
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Hash the queryKey
  2. Lookup in QueryCache
  3. Return state.data (or undefined)
  4. No fetch triggered
  5. 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
  })
}
Enter fullscreen mode Exit fullscreen mode

2. getQueriesData(filters)

Get data from multiple queries at once.

const allUserData = queryClient.getQueriesData({ queryKey: ['users'] })
// Returns: [[queryKey, data], [queryKey, data], ...]
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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, ... }
Enter fullscreen mode Exit fullscreen mode

Returns:

{
  data,
  dataUpdatedAt,
  error,
  errorUpdatedAt,
  fetchFailureCount,
  fetchFailureReason,
  fetchStatus,
  isInvalidated,
  status
}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

Type:

setQueryData<TData>(
  queryKey: QueryKey,
  updater: TData | ((old: TData | undefined) => TData)
): TData | undefined
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find/create Query instance
  2. Apply updater function
  3. Structural sharing - reuse unchanged references
  4. Update state
  5. Notify observers
  6. Trigger React re-renders
  7. 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)
  }
})
Enter fullscreen mode Exit fullscreen mode

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()
}, [])
Enter fullscreen mode Exit fullscreen mode

2. setQueriesData(filters, updater)

Batch update multiple queries.

queryClient.setQueriesData(
  { queryKey: ['users'] },
  (old) => old.map(user => ({ ...user, online: true }))
)
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find all matching queries
  2. Apply updater to each
  3. Single batched notification
  4. 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
})
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Check if Query exists → reuse it
  2. Check stale time
  3. If needed, start fetch via Retryer
  4. Return promise
  5. 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

2. prefetchQuery(options)

Fetch data without returning it (fire-and-forget).

queryClient.prefetchQuery({
  queryKey: ['users'],
  queryFn: fetchUsers
})
Enter fullscreen mode Exit fullscreen mode

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 />
  }
])
Enter fullscreen mode Exit fullscreen mode

Example - Hover prefetch:

<Link
  to="/user/123"
  onMouseEnter={() => {
    queryClient.prefetchQuery({
      queryKey: ['user', 123],
      queryFn: () => fetchUser(123)
    })
  }}
>
  View User
</Link>
Enter fullscreen mode Exit fullscreen mode

3. ensureQueryData(options)

Guarantee data exists in cache.

const users = await queryClient.ensureQueryData({
  queryKey: ['users'],
  queryFn: fetchUsers
})
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Check cache
  2. If data exists → return it
  3. If missing → fetch and return
  4. 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 }
}
Enter fullscreen mode Exit fullscreen mode

4. invalidateQueries(filters)

Mark queries as stale (they'll refetch if active).

queryClient.invalidateQueries({ queryKey: ['users'] })
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find all matching queries
  2. Set isInvalidated = true
  3. If query has active observers → schedule refetch
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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'] })
  }
})
Enter fullscreen mode Exit fullscreen mode

Example - Prefix matching:

// Invalidate all user-related queries
queryClient.invalidateQueries({ queryKey: ['users'] })

// Matches:
// ['users']
// ['users', 1]
// ['users', 1, 'posts']
// etc.
Enter fullscreen mode Exit fullscreen mode

5. refetchQueries(filters)

Force refetch queries immediately.

await queryClient.refetchQueries({ queryKey: ['users'] })
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!')
}
Enter fullscreen mode Exit fullscreen mode

6. cancelQueries(filters)

Cancel ongoing fetches.

await queryClient.cancelQueries({ queryKey: ['users'] })
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find all matching queries
  2. Call retryer.cancel()
  3. If fetch uses AbortController → trigger abort
  4. 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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

7. removeQueries(filters)

Completely remove queries from cache.

queryClient.removeQueries({ queryKey: ['users'] })
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find matching queries
  2. Detach all observers
  3. Delete from QueryCache
  4. Free memory
  5. 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')
}
Enter fullscreen mode Exit fullscreen mode

8. resetQueries(filters)

Reset queries to initial state.

queryClient.resetQueries({ queryKey: ['users'] })
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Reset state to initial values
  2. Trigger refetch if active
  3. 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()
Enter fullscreen mode Exit fullscreen mode

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'] 
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

3. resumePausedMutations()

Resume offline-queued mutations.

await queryClient.resumePausedMutations()
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Find paused mutations
  2. Check network status
  3. Resume retryer
  4. 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'] })
Enter fullscreen mode Exit fullscreen mode

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 />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

2. clear()

Wipe everything - queries and mutations.

queryClient.clear()
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Clear QueryCache
  2. Clear MutationCache
  3. Detach all observers
  4. 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()
Enter fullscreen mode Exit fullscreen mode

4. setDefaultOptions(options)

Update global defaults.

queryClient.setDefaultOptions({
  queries: {
    staleTime: 30000
  }
})
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

What it does:

  • User switches back to tab → trigger refetch for stale queries

Controlled by:

refetchOnWindowFocus: true  // Query option
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Focus event fires
  2. FocusManager notifies QueryClient
  3. QueryClient checks all active queries
  4. Stale queries refetch

2. OnlineManager 🌐

Listens to network events.

window.addEventListener('online')
window.addEventListener('offline')
Enter fullscreen mode Exit fullscreen mode

What it does:

  • Network reconnects → resume paused queries/mutations

Controlled by:

refetchOnReconnect: true
networkMode: 'online'
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Online event fires
  2. OnlineManager notifies QueryClient
  3. Paused retryers resume
  4. 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:

  1. Observer state changes
  2. NotifyManager collects notifications
  3. React batching applies
  4. 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:

  1. Query becomes inactive (no observers)
  2. GC timer starts (gcTime)
  3. Timer expires → query removed from cache

Controlled by:

gcTime: 5 * 60 * 1000  // 5 minutes (default)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. New data arrives
  2. Deep comparison with old data
  3. Unchanged branches → reuse old references
  4. Changed branches → use new references
  5. 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
Enter fullscreen mode Exit fullscreen mode
  1. Component calls useQuery
  2. Creates QueryObserver
  3. Observer subscribes to Query
  4. Query state changes
  5. Observer notifies component
  6. Component re-renders

Smart updates:

  • Observer tracks which properties component accessed
  • Only notifies if those properties changed
  • This is why notifyOnChangeProps works

3. Retryer System 🔁

Handles all retry logic.

Each Query/Mutation has a Retryer instance.

What it does:

  1. Execute fetch function
  2. If fails → check retry logic
  3. Wait (exponential backoff)
  4. Retry
  5. 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
Enter fullscreen mode Exit fullscreen mode

Can be cancelled:

retryer.cancel()  // Aborts current fetch
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 />
  }
]
Enter fullscreen mode Exit fullscreen mode

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'] })
  }
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')
  }
})
Enter fullscreen mode Exit fullscreen mode

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()
}, [])
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

⚡ 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
})
Enter fullscreen mode Exit fullscreen mode

2. Selective Invalidation

// ❌ BAD - Invalidates everything
queryClient.invalidateQueries()

// ✅ GOOD - Targeted invalidation
queryClient.invalidateQueries({ 
  queryKey: ['users', userId] 
})
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

4. Optimize Re-renders

// Only re-render when data changes
const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => data.users,
  notifyOnChangeProps: ['data']
})
Enter fullscreen mode Exit fullscreen mode

⚠️ 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}>...</>
}
Enter fullscreen mode Exit fullscreen mode

❌ 2. Clearing Cache Too Aggressively

// BAD - Loses all data
queryClient.clear()

// GOOD - Remove specific data
queryClient.removeQueries({ queryKey: ['user'] })
Enter fullscreen mode Exit fullscreen mode

❌ 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)
}
Enter fullscreen mode Exit fullscreen mode

❌ 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>
}
Enter fullscreen mode Exit fullscreen mode

❌ 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!')
  }
})
Enter fullscreen mode Exit fullscreen mode

🎯 Key Takeaways

QueryClient is:

  1. Central orchestrator - Controls all queries/mutations
  2. Cache database - Stores all server state
  3. Scheduler - Manages refetch timing
  4. Observer manager - Notifies components
  5. Retry engine - Handles failures
  6. GC system - Prevents memory leaks
  7. 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

  1. Read TanStack Query docs
  2. Explore QueryCache and MutationCache APIs
  3. Study Retryer implementation
  4. Build custom DevTools
  5. 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)