DEV Community

Munna Thakur
Munna Thakur

Posted on

React Query Architecture — Complete Flow from Hook to Render

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


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

Key Insight:

React Query is NOT React state.

It's an external store that React observes via useSyncExternalStore.


🏗 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                                 │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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

What component does:

  • Calls useQuery hook
  • 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
}
Enter fullscreen mode Exit fullscreen mode

Key responsibilities:

  1. Create/reuse QueryObserver
  2. Subscribe to observer via useSyncExternalStore
  3. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

Key responsibilities:

  1. Bridge between React and Query
  2. Track which properties component uses
  3. Diff state changes efficiently
  4. 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key responsibilities:

  1. Store query state
  2. Handle fetch lifecycle
  3. Manage observers
  4. 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)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Key responsibilities:

  1. Store all Query instances
  2. Fast lookup by hash
  3. Garbage collection
  4. 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} />                             │
│  }                                                          │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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

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

What This Does:

  1. Subscribe function:

    • Called once on mount
    • Returns unsubscribe function
    • React manages the subscription
  2. Get snapshot:

    • Called on every render
    • Returns current state
    • Must be pure and fast
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

🔄 useSyncExternalStore Explained {#usesyncexternalstore-explained}

API:

useSyncExternalStore<Snapshot>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot
): Snapshot
Enter fullscreen mode Exit fullscreen mode

Parameters:

1. subscribe

Called once on mount.

const subscribe = (onStoreChange) => {
  // Add listener
  const unsubscribe = observer.subscribe(onStoreChange)

  // Return cleanup
  return unsubscribe
}
Enter fullscreen mode Exit fullscreen mode

When observer detects change:

// Inside QueryObserver
if (this.hasResultChanged(newResult)) {
  // Notify React
  this.listeners.forEach(listener => listener())
}
Enter fullscreen mode Exit fullscreen mode

2. getSnapshot

Called on every render.

const getSnapshot = () => {
  return observer.getCurrentResult()
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

2. Use notifyOnChangeProps for heavy components:

const { data } = useQuery(['users'], fetchUsers, {
  notifyOnChangeProps: ['data']
})
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Result:

// Multiple updates
query.setState({ fetchStatus: 'fetching' })
query.setState({ data: [...] })
query.setState({ status: 'success' })

// Only ONE React render at the end!
Enter fullscreen mode Exit fullscreen mode

3. Property Tracking

Only re-render when accessed properties change.

const { data } = useQuery(['users'], fetchUsers)

// Tracked: ['data']
// Ignores changes to: isLoading, error, isFetching, etc.
Enter fullscreen mode Exit fullscreen mode

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

🧠 Mental Models {#mental-models}

Model 1: Query as Database Row

Query = Row in database
QueryKey = Primary key
QueryCache = Database table
QueryObserver = Database subscription
Enter fullscreen mode Exit fullscreen mode

Model 2: Pub/Sub Pattern

Query = Publisher
QueryObserver = Subscriber
Query.notify() = Publish event
Observer.onQueryUpdate() = Handle event
Enter fullscreen mode Exit fullscreen mode

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

🎯 Key Takeaways

Architecture:

  1. Layered design - Clean separation of concerns
  2. Framework agnostic - Core logic independent of React
  3. Observer pattern - Fine-grained subscriptions
  4. useSyncExternalStore - Safe concurrent rendering

Flow:

  1. Component calls useQuery
  2. Creates QueryObserver (bridge)
  3. Subscribes via useSyncExternalStore
  4. Observer gets/creates Query from cache
  5. Query fetches if needed
  6. State changes notify observers
  7. Observers diff and notify React
  8. React re-renders component

Optimizations:

  1. Structural sharing - Reuse unchanged references
  2. Property tracking - Only re-render when needed
  3. Batched notifications - Multiple updates → single render
  4. 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

  1. Read useSyncExternalStore RFC
  2. Explore React Query source code
  3. Build custom QueryObserver
  4. Profile with React DevTools Profiler
  5. 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)