Ever wondered what actually happens when you call useQuery?
How does React Query:
- ✅ Connect your component to the cache?
- ✅ Know when to re-render?
- ✅ Avoid unnecessary renders?
- ✅ Work with React 18's concurrent features?
This guide shows you the complete internal flow from hook call to screen update.
From @tanstack/react-query
📋 Table of Contents
- The Big Picture
- Complete Architecture Diagram
- Layer-by-Layer Breakdown
- The Complete Flow
- React Integration
- QueryObserver Deep Dive
- useSyncExternalStore Explained
- Re-render Logic
- Real Code Walkthrough
- Performance Optimizations
- Mental Models
⚡ Quick Summary
TL;DR: React Query uses:
- ✅ QueryObserver as the bridge between React and cache
- ✅ useSyncExternalStore to subscribe to external state
- ✅ Structural sharing to prevent unnecessary renders
- ✅ Observer pattern for fine-grained updates
- ✅ Batched notifications via NotifyManager
Length: ~35 min read | Level: Advanced
🎯 The Big Picture {#the-big-picture}
Simple Mental Model:
React Component
↓
useQuery (Hook)
↓
QueryObserver (Bridge)
↓
Query (State Machine)
↓
QueryCache (Database)
↓
QueryClient (Orchestrator)
Key Insight:
React Query is NOT React state.
It's an external store that React observes viauseSyncExternalStore.
🏗 Complete Architecture Diagram {#complete-architecture-diagram}
Visual Overview:
┌─────────────────────────────────────────────────────────────┐
│ React Component │
│ const { data, isLoading } = useQuery({ queryKey, queryFn })│
└────────────────────┬────────────────────────────────────────┘
│
│ (1) Hook call
↓
┌─────────────────────────────────────────────────────────────┐
│ useQuery Hook │
│ - Creates/reuses QueryObserver │
│ - Calls useSyncExternalStore │
│ - Returns result to component │
└────────────────────┬────────────────────────────────────────┘
│
│ (2) Create observer
↓
┌─────────────────────────────────────────────────────────────┐
│ QueryObserver │
│ Role: Bridge between React and Query │
│ - Subscribes to Query │
│ - Tracks accessed properties │
│ - Diffs state changes │
│ - Notifies React when needed │
└────────────────────┬────────────────────────────────────────┘
│
│ (3) Subscribe to Query
↓
┌─────────────────────────────────────────────────────────────┐
│ Query │
│ Role: Individual query state machine │
│ - Manages state (data, error, status) │
│ - Handles fetch logic via Retryer │
│ - Notifies observers on state change │
│ - Stored in QueryCache │
└────────────────────┬────────────────────────────────────────┘
│
│ (4) Stored in
↓
┌─────────────────────────────────────────────────────────────┐
│ QueryCache │
│ Role: Database of all Query instances │
│ - Map<queryHash, Query> │
│ - Fast O(1) lookup │
│ - Garbage collection │
│ - Global event emitter │
└────────────────────┬────────────────────────────────────────┘
│
│ (5) Accessed via
↓
┌─────────────────────────────────────────────────────────────┐
│ QueryClient │
│ Role: Central orchestrator │
│ - API for interacting with cache │
│ - Invalidation, refetch, mutations │
│ - Configuration management │
└─────────────────────────────────────────────────────────────┘
📊 Layer-by-Layer Breakdown {#layer-by-layer-breakdown}
Layer 1: React Component 🎨
Your code:
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
})
if (isLoading) return <Spinner />
if (error) return <Error />
return <List data={data} />
}
What component does:
- Calls
useQueryhook - Receives reactive result
- Re-renders when result changes
Layer 2: useQuery Hook 🪝
Internal implementation (simplified):
function useQuery(options) {
const queryClient = useQueryClient()
// Create observer (memoized)
const observer = React.useMemo(
() => new QueryObserver(queryClient, options),
[]
)
// Update options on every render
observer.setOptions(options)
// Subscribe using React 18's useSyncExternalStore
const result = useSyncExternalStore(
// Subscribe function
(onStoreChange) => observer.subscribe(onStoreChange),
// Get current snapshot
() => observer.getCurrentResult(),
// Get server snapshot (for SSR)
() => observer.getCurrentResult()
)
return result
}
Key responsibilities:
- Create/reuse QueryObserver
- Subscribe to observer via
useSyncExternalStore - Return current result
Layer 3: QueryObserver 👀
Internal structure:
class QueryObserver {
constructor(client, options) {
this.client = client
this.options = options
this.listeners = new Set()
this.currentQuery = undefined
this.currentResult = undefined
this.trackedProps = new Set()
}
// Called by useSyncExternalStore
subscribe(listener) {
this.listeners.add(listener)
// Get or create query
this.updateQuery()
// Subscribe to query
const unsubscribe = this.currentQuery.subscribe(this)
return () => {
this.listeners.delete(listener)
unsubscribe()
}
}
// Get current result for component
getCurrentResult() {
return this.currentResult
}
// Called when query updates
onQueryUpdate() {
// Create new result
const result = this.createResult()
// Check if changed
if (this.hasResultChanged(result)) {
this.currentResult = result
// Notify React
this.listeners.forEach(listener => listener())
}
}
// Track which properties component accessed
createResult() {
const query = this.currentQuery
const state = query.state
return {
data: state.data,
error: state.error,
isLoading: state.status === 'pending',
isSuccess: state.status === 'success',
isError: state.status === 'error',
// ... more properties
}
}
// Smart diffing
hasResultChanged(result) {
// If no tracked props, check all
if (this.trackedProps.size === 0) {
return !shallowEqual(this.currentResult, result)
}
// Only check tracked props
for (const key of this.trackedProps) {
if (this.currentResult[key] !== result[key]) {
return true
}
}
return false
}
}
Key responsibilities:
- Bridge between React and Query
- Track which properties component uses
- Diff state changes efficiently
- Notify React only when needed
Layer 4: Query 🔧
Internal structure:
class Query {
constructor(config) {
this.queryKey = config.queryKey
this.queryHash = hashQueryKey(config.queryKey)
this.options = config.options
// State
this.state = {
data: undefined,
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
fetchStatus: 'idle',
status: 'pending'
}
// Observers (components watching this query)
this.observers = new Set()
// Current fetch promise
this.promise = null
// Retry controller
this.retryer = null
// GC timer
this.gcTimeout = null
}
// Add observer (component)
subscribe(observer) {
this.observers.add(observer)
this.clearGcTimeout()
// Notify observer of current state
observer.onQueryUpdate()
return () => {
this.observers.delete(observer)
// No observers? Schedule GC
if (this.observers.size === 0) {
this.scheduleGc()
}
}
}
// Fetch data
async fetch() {
// Already fetching?
if (this.promise) {
return this.promise
}
// Update state
this.setState({
fetchStatus: 'fetching'
})
// Create retryer
this.retryer = new Retryer({
fn: this.options.queryFn,
retry: this.options.retry,
retryDelay: this.options.retryDelay
})
// Start fetch
this.promise = this.retryer.start()
.then(data => {
this.setState({
data,
dataUpdatedAt: Date.now(),
status: 'success',
fetchStatus: 'idle',
error: null
})
return data
})
.catch(error => {
this.setState({
error,
errorUpdatedAt: Date.now(),
status: 'error',
fetchStatus: 'idle'
})
throw error
})
.finally(() => {
this.promise = null
})
return this.promise
}
// Update state and notify observers
setState(updater) {
this.state = { ...this.state, ...updater }
// Notify all observers
this.notify()
}
// Notify observers
notify() {
notifyManager.batch(() => {
this.observers.forEach(observer => {
observer.onQueryUpdate()
})
})
}
// Schedule garbage collection
scheduleGc() {
this.clearGcTimeout()
this.gcTimeout = setTimeout(() => {
this.cache.remove(this)
}, this.options.gcTime)
}
clearGcTimeout() {
if (this.gcTimeout) {
clearTimeout(this.gcTimeout)
this.gcTimeout = null
}
}
}
Key responsibilities:
- Store query state
- Handle fetch lifecycle
- Manage observers
- Notify on state changes
Layer 5: QueryCache 🗄️
Internal structure:
class QueryCache {
constructor(config) {
this.config = config
this.queries = []
this.queriesMap = new Map()
}
// Build or get query
build(client, options) {
const queryKey = options.queryKey
const queryHash = hashQueryKey(queryKey)
// Existing query?
let query = this.queriesMap.get(queryHash)
// Create new if needed
if (!query) {
query = new Query({
cache: this,
queryKey,
queryHash,
options,
state: options.initialData
? {
data: options.initialData,
status: 'success',
dataUpdatedAt: Date.now()
}
: undefined
})
this.queries.push(query)
this.queriesMap.set(queryHash, query)
this.notify({ type: 'added', query })
}
return query
}
// Find query by key
find(queryKey) {
const queryHash = hashQueryKey(queryKey)
return this.queriesMap.get(queryHash)
}
// Find all matching queries
findAll(filters) {
return this.queries.filter(query =>
matchQuery(filters, query)
)
}
// Remove query
remove(query) {
const index = this.queries.indexOf(query)
if (index !== -1) {
this.queries.splice(index, 1)
this.queriesMap.delete(query.queryHash)
this.notify({ type: 'removed', query })
}
}
// Notify listeners (e.g., DevTools)
notify(event) {
this.listeners.forEach(listener => {
listener(event)
})
}
}
Key responsibilities:
- Store all Query instances
- Fast lookup by hash
- Garbage collection
- Event emission
Layer 6: QueryClient 🧠
We covered this in detail in the QueryClient guide!
🔄 The Complete Flow {#the-complete-flow}
Step-by-Step Execution:
┌─────────────────────────────────────────────────────────────┐
│ Step 1: Component Mounts │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ function UserList() { │
│ const result = useQuery({ │
│ queryKey: ['users'], │
│ queryFn: fetchUsers │
│ }) │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 2: useQuery Hook Executes │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ // Inside useQuery │
│ const observer = useMemo( │
│ () => new QueryObserver(client, options), │
│ [] │
│ ) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 3: QueryObserver Created │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ QueryObserver { │
│ client: QueryClient │
│ options: { queryKey, queryFn } │
│ listeners: Set() │
│ currentQuery: undefined │
│ currentResult: undefined │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 4: useSyncExternalStore Called │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ useSyncExternalStore( │
│ // Subscribe callback │
│ (onStoreChange) => { │
│ return observer.subscribe(onStoreChange) │
│ }, │
│ // Get snapshot │
│ () => observer.getCurrentResult() │
│ ) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 5: Observer Subscribes to Query │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ observer.subscribe(onStoreChange) { │
│ // Get or create query │
│ const query = queryCache.build(options) │
│ │
│ // Subscribe to query │
│ const unsubscribe = query.subscribe(this) │
│ │
│ // Store listener │
│ this.listeners.add(onStoreChange) │
│ │
│ return () => { │
│ unsubscribe() │
│ this.listeners.delete(onStoreChange) │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 6: Query Built/Retrieved from Cache │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ queryCache.build(options) { │
│ const queryHash = hash(['users']) │
│ │
│ // Check if exists │
│ let query = this.queriesMap.get(queryHash) │
│ │
│ // Create if new │
│ if (!query) { │
│ query = new Query({ │
│ queryKey: ['users'], │
│ queryHash: '["users"]', │
│ options │
│ }) │
│ this.queriesMap.set(queryHash, query) │
│ } │
│ │
│ return query │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 7: Query Adds Observer │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ query.subscribe(observer) { │
│ // Add to observers set │
│ this.observers.add(observer) │
│ │
│ // Cancel GC │
│ this.clearGcTimeout() │
│ │
│ // Notify observer immediately │
│ observer.onQueryUpdate() │
│ │
│ return unsubscribe function │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 8: Check if Fetch Needed │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ // Inside observer.onQueryUpdate() │
│ if (!query.state.data || query.isStale()) { │
│ query.fetch() │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 9: Fetch Starts │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ query.fetch() { │
│ // Update state │
│ this.setState({ │
│ fetchStatus: 'fetching' │
│ }) │
│ │
│ // Execute queryFn │
│ this.promise = fetchUsers() │
│ .then(data => { │
│ this.setState({ │
│ data, │
│ status: 'success', │
│ fetchStatus: 'idle', │
│ dataUpdatedAt: Date.now() │
│ }) │
│ }) │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 10: State Updates Trigger Notifications │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ query.setState(update) { │
│ // Merge state │
│ this.state = { ...this.state, ...update } │
│ │
│ // Notify all observers │
│ this.notify() │
│ } │
│ │
│ query.notify() { │
│ notifyManager.batch(() => { │
│ this.observers.forEach(observer => { │
│ observer.onQueryUpdate() │
│ }) │
│ }) │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 11: Observer Receives Update │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ observer.onQueryUpdate() { │
│ // Create new result │
│ const result = this.createResult() │
│ │
│ // Check if changed │
│ if (this.hasResultChanged(result)) { │
│ this.currentResult = result │
│ │
│ // Notify React via useSyncExternalStore │
│ this.listeners.forEach(listener => { │
│ listener() // Triggers React re-render │
│ }) │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Step 12: React Re-renders Component │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ function UserList() { │
│ // useSyncExternalStore returns new snapshot │
│ const { data, isLoading } = useQuery(...) │
│ │
│ // Component re-renders with new data │
│ return <List data={data} /> │
│ } │
└─────────────────────────────────────────────────────────────┘
⚛️ React Integration {#react-integration}
Why useSyncExternalStore?
React 18 introduced useSyncExternalStore for exactly this use case:
Subscribing to external stores (non-React state) safely with concurrent rendering.
The Problem Without It:
Before React 18:
// Old approach (tearing risk)
function useExternalStore(store) {
const [state, setState] = useState(store.getState())
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState())
})
return unsubscribe
}, [])
return state
}
Problem: Tearing in concurrent mode
- Component might render with stale data
- Inconsistent UI during suspense
The Solution:
useSyncExternalStore guarantees:
- ✅ No tearing
- ✅ Concurrent-safe
- ✅ Consistent snapshots
How React Query Uses It:
function useQuery(options) {
const queryClient = useQueryClient()
const observer = useMemo(
() => new QueryObserver(queryClient, options),
[]
)
// Update options every render
observer.setOptions(options)
// Subscribe to observer
const result = useSyncExternalStore(
// Subscribe function
React.useCallback(
(onStoreChange) => observer.subscribe(onStoreChange),
[observer]
),
// Get current snapshot (client)
() => observer.getCurrentResult(),
// Get server snapshot (SSR)
() => observer.getCurrentResult()
)
return result
}
What This Does:
-
Subscribe function:
- Called once on mount
- Returns unsubscribe function
- React manages the subscription
-
Get snapshot:
- Called on every render
- Returns current state
- Must be pure and fast
-
React handles:
- Subscribing/unsubscribing
- Detecting changes
- Triggering re-renders
- Avoiding tearing
👁️ QueryObserver Deep Dive {#queryobserver-deep-dive}
What is QueryObserver?
The bridge between React and Query.
Why Not Subscribe Directly?
// ❌ Why not this?
function useQuery(options) {
const query = getQuery(options.queryKey)
const [state, setState] = useState(query.state)
useEffect(() => {
return query.subscribe(() => setState(query.state))
}, [])
return state
}
Problems:
- ❌ Re-renders on ANY state change (even unrelated)
- ❌ No property tracking
- ❌ No smart diffing
- ❌ No batching
QueryObserver Solves This:
class QueryObserver {
// Track which properties component accessed
trackedProps = new Set()
createResult() {
const query = this.currentQuery
const state = query.state
// Create proxy to track access
return new Proxy({
data: state.data,
error: state.error,
isLoading: state.status === 'pending',
// ... more properties
}, {
get: (target, prop) => {
// Track that component accessed this prop
this.trackedProps.add(prop)
return target[prop]
}
})
}
hasResultChanged(result) {
// Only check tracked props
for (const key of this.trackedProps) {
if (this.currentResult[key] !== result[key]) {
return true // Re-render needed
}
}
return false // Skip re-render
}
}
Example:
function Component() {
// Only accesses 'data'
const { data } = useQuery(['users'], fetchUsers)
return <div>{data}</div>
}
// Tracked props: ['data']
// Won't re-render when isLoading, error, etc. change!
🔄 useSyncExternalStore Explained {#usesyncexternalstore-explained}
API:
useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
): Snapshot
Parameters:
1. subscribe
Called once on mount.
const subscribe = (onStoreChange) => {
// Add listener
const unsubscribe = observer.subscribe(onStoreChange)
// Return cleanup
return unsubscribe
}
When observer detects change:
// Inside QueryObserver
if (this.hasResultChanged(newResult)) {
// Notify React
this.listeners.forEach(listener => listener())
}
2. getSnapshot
Called on every render.
const getSnapshot = () => {
return observer.getCurrentResult()
}
Must be:
- ✅ Pure (same input → same output)
- ✅ Fast (called frequently)
- ✅ Synchronous
- ✅ Returns immutable snapshot
React compares snapshots:
const prev = getSnapshot()
// ... time passes ...
const next = getSnapshot()
if (prev !== next) {
// Re-render component
}
3. getServerSnapshot (optional)
For SSR/SSG.
Returns initial state during server rendering.
How React Uses It:
Component Render
↓
Call getSnapshot() → Get current state
↓
Compare with previous snapshot
↓
Different? → Re-render
Same? → Skip render
↓
Wait for notification from subscribe callback
↓
On notification → Call getSnapshot() again
Why This Is Safe:
Concurrent rendering problem:
// Component might render multiple times before committing
Render 1: getSnapshot() → { count: 0 }
Render 2: getSnapshot() → { count: 1 } // Store updated
Render 3: getSnapshot() → { count: 2 } // Store updated again
Commit: Use latest snapshot { count: 2 }
useSyncExternalStore ensures:
- Always uses consistent snapshot
- No partial/stale states
- No tearing
🎨 Re-render Logic {#re-render-logic}
When Does React Query Re-render?
Decision tree:
Query state changes
↓
Query.notify() called
↓
All observers notified
↓
QueryObserver.onQueryUpdate()
↓
Create new result snapshot
↓
Compare with previous result
↓
├─ No tracked props changed
│ ↓
│ Skip notification
│ (No re-render)
│
└─ Tracked props changed
↓
Notify React via listener
↓
useSyncExternalStore detects change
↓
Component re-renders
Example Scenarios:
Scenario 1: Only 'data' accessed
function Component() {
const { data } = useQuery(['users'], fetchUsers)
return <div>{data?.length} users</div>
}
// Tracked: ['data']
// Re-renders only when data changes
Timeline:
t0: { data: undefined, isLoading: true }
→ No re-render (data unchanged)
t1: { data: [...], isLoading: false }
→ RE-RENDER (data changed)
t2: { data: [...], isFetching: true }
→ No re-render (data unchanged)
t3: { data: [...], isFetching: false }
→ No re-render (data unchanged)
Scenario 2: Multiple properties accessed
function Component() {
const { data, isLoading } = useQuery(['users'], fetchUsers)
if (isLoading) return <Spinner />
return <div>{data?.length} users</div>
}
// Tracked: ['data', 'isLoading']
// Re-renders when either changes
Timeline:
t0: { data: undefined, isLoading: true }
→ No re-render (initial)
t1: { data: undefined, isLoading: true, isFetching: true }
→ No re-render (tracked props unchanged)
t2: { data: [...], isLoading: false }
→ RE-RENDER (both changed)
Scenario 3: Override tracking
const { data } = useQuery(['users'], fetchUsers, {
notifyOnChangeProps: ['data'] // Only notify for data
})
// Forces tracking of only 'data'
// Even if component accesses other props
Optimization Tips:
1. Only destructure what you need:
// ❌ Bad - tracks all props
const result = useQuery(['users'], fetchUsers)
return <div>{result.data}</div>
// ✅ Good - tracks only data
const { data } = useQuery(['users'], fetchUsers)
return <div>{data}</div>
2. Use notifyOnChangeProps for heavy components:
const { data } = useQuery(['users'], fetchUsers, {
notifyOnChangeProps: ['data']
})
3. Use select for derived state:
const { data: userCount } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (data) => data.length // Memoized!
})
// Only re-renders when length changes
💻 Real Code Walkthrough {#real-code-walkthrough}
Complete Example:
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'
// 1. Create QueryClient
const queryClient = new QueryClient()
// 2. Provide to app
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
)
}
// 3. Component using useQuery
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users')
return res.json()
}
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
What Actually Happens:
App Render
↓
QueryClientProvider creates context
↓
UserList mounts
↓
useQuery called
↓
├─ useQueryClient() → gets QueryClient from context
│
├─ useMemo() → creates QueryObserver (once)
│ ↓
│ new QueryObserver(queryClient, options)
│
├─ observer.setOptions() → updates options
│
└─ useSyncExternalStore() → subscribes to observer
↓
observer.subscribe(onStoreChange) called
↓
QueryCache.build() → gets/creates Query
↓
Query.subscribe(observer) → adds observer to set
↓
Query checks if fetch needed
↓
Yes → Query.fetch() starts
↓
Query.setState({ fetchStatus: 'fetching' })
↓
Query.notify() → notifies observers
↓
QueryObserver.onQueryUpdate() → creates new result
↓
Compares { data: undefined, isLoading: true }
with previous (undefined)
↓
Changed! → calls listener()
↓
useSyncExternalStore detects change
↓
Component re-renders
↓
Shows "Loading..."
↓
... fetch completes ...
↓
Query.setState({ data: [...], status: 'success' })
↓
Query.notify()
↓
Observer compares { data: [...], isLoading: false }
↓
Changed! → notify React
↓
Component re-renders
↓
Shows user list
⚡ Performance Optimizations {#performance-optimizations}
1. Structural Sharing
Prevents unnecessary re-renders by reusing unchanged references.
// Old data
const oldData = { users: [{ id: 1, name: 'John' }] }
// New data (same content)
const newData = { users: [{ id: 1, name: 'John' }] }
// After structural sharing
oldData.users[0] === newData.users[0] // true!
// React component using users[0] won't re-render
2. Batched Notifications
Multiple state changes → single render.
// Inside NotifyManager
class NotifyManager {
batch(callback) {
// Collect all notifications
this.isBatching = true
callback()
this.isBatching = false
// Flush once
this.flush()
}
schedule(listener) {
if (this.isBatching) {
// Queue it
this.queue.add(listener)
} else {
// Execute immediately
listener()
}
}
flush() {
// Execute all queued notifications
this.queue.forEach(listener => listener())
this.queue.clear()
}
}
Result:
// Multiple updates
query.setState({ fetchStatus: 'fetching' })
query.setState({ data: [...] })
query.setState({ status: 'success' })
// Only ONE React render at the end!
3. Property Tracking
Only re-render when accessed properties change.
const { data } = useQuery(['users'], fetchUsers)
// Tracked: ['data']
// Ignores changes to: isLoading, error, isFetching, etc.
4. Fetch Deduplication
Same query requested multiple times → single network request.
// 10 components mount simultaneously
function Component() {
useQuery(['users'], fetchUsers)
}
// Only 1 network request!
// All components share the same Query.promise
🧠 Mental Models {#mental-models}
Model 1: Query as Database Row
Query = Row in database
QueryKey = Primary key
QueryCache = Database table
QueryObserver = Database subscription
Model 2: Pub/Sub Pattern
Query = Publisher
QueryObserver = Subscriber
Query.notify() = Publish event
Observer.onQueryUpdate() = Handle event
Model 3: React Integration
React Query = External store
useSyncExternalStore = React's bridge to external world
QueryObserver = Adapter that speaks React
Query = Pure async state machine
🎯 Key Takeaways
Architecture:
- Layered design - Clean separation of concerns
- Framework agnostic - Core logic independent of React
- Observer pattern - Fine-grained subscriptions
- useSyncExternalStore - Safe concurrent rendering
Flow:
- Component calls
useQuery - Creates
QueryObserver(bridge) - Subscribes via
useSyncExternalStore - Observer gets/creates
Queryfrom cache - Query fetches if needed
- State changes notify observers
- Observers diff and notify React
- React re-renders component
Optimizations:
- Structural sharing - Reuse unchanged references
- Property tracking - Only re-render when needed
- Batched notifications - Multiple updates → single render
- Fetch deduplication - Share promises
📌 Final Thought
React Query's architecture is brilliant because:
✅ Separation of concerns - Each layer has clear responsibility
✅ Framework agnostic - Core can work with any framework
✅ Performance first - Smart diffing and batching
✅ Concurrent safe - Uses React 18's best practices
✅ Developer friendly - Simple API, complex internals
Understanding this architecture helps you:
- Debug effectively
- Optimize performance
- Build advanced patterns
- Interview confidently
- Appreciate the engineering
🚀 Next Steps
- Read useSyncExternalStore RFC
- Explore React Query source code
- Build custom QueryObserver
- Profile with React DevTools Profiler
- Contribute to React Query
💡 Pro Tips
Deepen your understanding:
- Study the QueryObserver source
- Build a mini React Query clone
- Profile your app's re-renders
- Use React DevTools Profiler
🤝 Let's Connect
Found this helpful?
- ❤️ Save for future reference
- 🔄 Share with your team
- 💬 Ask questions in comments
- 🐦 Follow for more deep dives
What part of the architecture surprised you most? Comment below! 👇
🔗 GitHub Repository:
https://github.com/TanStack/query
Happy architecting! 🎯
Tags: #react #tanstack #reactquery #javascript #webdev #architecture #usesyncexternalstore #performance
Top comments (0)