| | |

TanStack Query 13 ⚛️ Parallel Queries — Executing concurrent requests

⏱️ Estimated read: 14 min | |

A component often needs several independent queries at once: a dashboard that shows user counts, recent orders, and revenue at the same time, or a profile page that fetches the user and the user’s projects in parallel. The previous chapter covered dependent queries, where one query waits for another. This chapter covers the opposite case: queries that have no dependency on each other and should run concurrently. The simplest way to run parallel queries is to call useQuery multiple times in the same component. React Query deduplicates the requests, and each hook has its own state and its own cache entry.

The useQueries hook is the mechanism for running a dynamic number of queries in parallel. When the number of queries is fixed and known at compile time, multiple useQuery calls are the clearest form. When the number of queries depends on a runtime value—the IDs in an array, the servers in a list—useQueries is the correct hook, because the number of useQuery calls cannot change between renders without violating the rules of hooks . The useQueries hook accepts an array of query configurations and returns an array of results in the same order.

Key point: Parallel queries are independent. Each query has its own cache key, its own loading state, and its own error state. The component must handle each state separately, or combine them with a helper that computes the overall loading and error flags. The useQueries hook accepts a combine option that transforms the array of results into a single value, which is useful for computing an aggregate like the total count or the combined error state . The combine function should be stable—defined outside the component or memoized—to avoid recomputing on every render.


Why parallel queries require explicit state handling

The independent-state problem.

A component with three useQuery calls has three isPending flags, three error objects, and three data values. The component must decide what to render while one query is loading and the others are done, and what to render when one query fails and the others succeed. The simplest approach is to render a single loading state when any query is pending, and a single error state when any query has an error, but this hides the progress of the successful queries . The alternative is to render each section with its own loading and error state, which shows the user the parts of the page that are ready.

The dynamic-count problem.

When the number of queries depends on a runtime array, the number of useQuery calls would change between renders, which violates the rules of hooks. The useQueries hook solves this by accepting an array of configurations and returning an array of results. The array can grow or shrink between renders without breaking the hook order, because the hook itself is called once and the array is its argument .

The combine problem.

The combine option is a function that receives the array of results and returns a single value. It is useful for computing an aggregate, such as the sum of the data or a combined error state. The function should be defined outside the component or wrapped in useCallback, because a new function on every render causes the hook to recompute the combined value on every render, which defeats the purpose of memoization .


a. Multiple useQuery calls

The simplest parallel-query pattern is to call useQuery once per independent resource. Each call has its own key, function, and state.

import { useQuery } from '@tanstack/react-query'

function Dashboard() {
  const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  const ordersQuery = useQuery({
    queryKey: ['orders'],
    queryFn: fetchOrders,
  })

  const revenueQuery = useQuery({
    queryKey: ['revenue'],
    queryFn: fetchRevenue,
  })

  if (usersQuery.isPending) return <p>Loading users...</p>
  if (ordersQuery.isPending) return <p>Loading orders...</p>
  if (revenueQuery.isPending) return <p>Loading revenue...</p>

  return (
    <div>
      <p>Users: {usersQuery.data.length}</p>
      <p>Orders: {ordersQuery.data.length}</p>
      <p>Revenue: {revenueQuery.data.total}</p>
    </div>
  )
}

The three queries run in parallel because they are independent and each one starts when the component mounts. React Query sends the three requests concurrently, and each one resolves independently . The component renders the loading state of the first pending query, which is a simple but sequential-looking approach. A better approach is to render each section with its own loading state, so that the user sees the users count as soon as it is available, even if the orders are still loading.

function Dashboard() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const ordersQuery = useQuery({ queryKey: ['orders'], queryFn: fetchOrders })
  const revenueQuery = useQuery({ queryKey: ['revenue'], queryFn: fetchRevenue })

  return (
    <div>
      <section>
        {usersQuery.isPending ? <Spinner /> : <p>Users: {usersQuery.data.length}</p>}
        {usersQuery.isError && <Error message={usersQuery.error.message} />}
      </section>
      <section>
        {ordersQuery.isPending ? <Spinner /> : <p>Orders: {ordersQuery.data.length}</p>}
        {ordersQuery.isError && <Error message={ordersQuery.error.message} />}
      </section>
      <section>
        {revenueQuery.isPending ? <Spinner /> : <p>Revenue: {revenueQuery.data.total}</p>}
        {revenueQuery.isError && <Error message={revenueQuery.error.message} />}
      </section>
    </div>
  )
}

The per-section approach renders each part of the page as soon as its query resolves, which is a better user experience than a single loading state for the whole page . The queries still run in parallel, and the user sees the available data immediately.


b. The useQueries hook for dynamic counts

The useQueries hook accepts an array of query configurations and returns an array of results. It is the correct hook when the number of queries depends on a runtime value.

import { useQueries } from '@tanstack/react-query'

function UserProfiles({ userIds }: { userIds: number[] }) {
  const userQueries = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
  })

  return (
    <div>
      {userQueries.map((query, index) => (
        <div key={userIds[index]}>
          {query.isPending ? (
            <Spinner />
          ) : query.isError ? (
            <Error message={query.error.message} />
          ) : (
            <p>{query.data.name}</p>
          )}
        </div>
      ))}
    </div>
  )
}

The userIds.map creates one configuration per ID, and the useQueries hook runs them all in parallel . The userQueries array has the same length as the userIds array, and each result corresponds to the configuration at the same index. The component iterates over the results and renders each one with its own loading and error state.

The combine option transforms the array of results into a single value. It is useful for computing an aggregate.

function UserCount({ userIds }: { userIds: number[] }) {
  const combined = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
    combine: (results) => ({
      total: results.reduce((sum, r) => sum + (r.data?.friends ?? 0), 0),
      isLoading: results.some((r) => r.isPending),
      isError: results.some((r) => r.isError),
    }),
  })

  if (combined.isLoading) return <Spinner />
  if (combined.isError) return <Error />
  return <p>Total friends: {combined.total}</p>
}

The combine function receives the array of results and returns an object with the aggregate values. The results.some checks compute the overall loading and error flags, and the results.reduce computes the total . The combine function should be defined outside the component, or wrapped in useCallback, to avoid recomputing the combined value on every render.


c. The combine function and stable references

The combine function is called on every render if it is defined inline. A new function on every render causes the hook to recompute the combined value, which is a performance issue for large result arrays. The function should be defined outside the component or memoized with useCallback.

// Defined outside the component: stable reference
function combineResults(results: UseQueryResult<User>[]) {
  return {
    total: results.reduce((sum, r) => sum + (r.data?.friends ?? 0), 0),
    isLoading: results.some((r) => r.isPending),
    isError: results.some((r) => r.isError),
  }
}

function UserCount({ userIds }: { userIds: number[] }) {
  const combined = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
    combine: combineResults,
  })
  // ...
}

The combineResults function is defined at the module level, so its reference is stable across renders. The hook memoizes the combined value based on the results and the function reference, and it does not recompute unless one of them changes . If the function needs to close over a prop or a state value, it should be wrapped in useCallback with the appropriate dependency array.

The combine function is also useful for returning a stable object that the component can use in a useEffect dependency array. Without combine, the component would have to memoize the aggregate itself, and the array of results is a new reference on every render.


Complete Example Session

// ============================================
// PART 1: THE INDEPENDENT QUERIES
// ============================================
import { useQuery, useQueries } from '@tanstack/react-query'

function Dashboard({ userIds }: { userIds: number[] }) {
  // Fixed number of queries: three useQuery calls
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const ordersQuery = useQuery({ queryKey: ['orders'], queryFn: fetchOrders })

// ============================================
// PART 2: THE DYNAMIC QUERIES
// ============================================
  // Dynamic number of queries: one per user ID
  const profileQueries = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['profile', id],
      queryFn: () => fetchProfile(id),
    })),
    combine: (results) => ({
      total: results.reduce((sum, r) => sum + (r.data?.followers ?? 0), 0),
      isLoading: results.some((r) => r.isPending),
      isError: results.some((r) => r.isError),
    }),
  })

// ============================================
// PART 3: THE PER-SECTION RENDER
// ============================================
  return (
    <div>
      <section>
        {usersQuery.isPending ? <Spinner /> : <p>Users: {usersQuery.data.length}</p>}
        {usersQuery.isError && <Error message={usersQuery.error.message} />}
      </section>

      <section>
        {ordersQuery.isPending ? <Spinner /> : <p>Orders: {ordersQuery.data.length}</p>}
        {ordersQuery.isError && <Error message={ordersQuery.error.message} />}
      </section>

// ============================================
// PART 4: THE COMBINED RESULT
// ============================================
      <section>
        {profileQueries.isLoading ? (
          <Spinner />
        ) : profileQueries.isError ? (
          <Error message="Failed to load profiles" />
        ) : (
          <p>Total followers: {profileQueries.total}</p>
        )}
      </section>
    </div>
  )
}

The four parts demonstrate the parallel-query patterns: the fixed useQuery calls, the dynamic useQueries call, the per-section render, and the combined result. The critical distinction is between Parts 1 and 2: the fixed queries are separate hooks, and the dynamic queries are a single hook with an array of configurations.


Quick Reference

Fixed vs Dynamic Queries

PatternHookUse Case
Fixed countMultiple useQuery callsKnown number of resources
Dynamic countuseQueriesNumber depends on runtime data

useQueries Options

OptionPurpose
queriesArray of query configurations
combineTransform the results array

Result Properties

PropertyPurpose
dataThe query’s data
isPendingTrue while loading
isErrorTrue on error
errorThe error object

Aggregate Computation

FunctionPurpose
results.some(r => r.isPending)Any query loading
results.some(r => r.isError)Any query failed
results.reduce(...)Sum or other aggregate

Best Practices

✅ Do This:

// Use multiple useQuery for a fixed number
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) # ✅

// Use useQueries for a dynamic number
const results = useQueries({ queries: ids.map(...) })                  # ✅

// Define combine outside the component
function combine(results) { ... }                                      # ✅

// Render per-section states
{query.isPending ? <Spinner /> : <Data />}                             # ✅

// Use some() for aggregate flags
isLoading: results.some((r) => r.isPending)                            # ✅

// Use reduce() for aggregates
total: results.reduce((sum, r) => sum + r.data.value, 0)               # ✅

❌ Don’t Do This:

// Loop useQuery inside a map
ids.map((id) => useQuery(...))    // Violates rules of hooks            # ❌

// Define combine inline
combine: (results) => ({ ... })    // New function every render          # ❌

// Single loading state for independent queries
if (anyPending) return <Spinner />    // Hides ready data               # ❌

// Ignore per-query errors
return <Data data={query.data} />    // Crashes on error                # ❌

// Use useQueries for a fixed number
useQueries({ queries: [q1, q2] })    // useQuery is clearer             # ❌

Common Pitfalls

PitfallWhy It HappensFix
Rules-of-hooks erroruseQuery called inside a mapUse useQueries
Combine recomputes every renderFunction defined inlineDefine outside the component
Whole page blockedSingle loading state for all queriesRender per-section
Missing error stateOnly isPending is checkedAdd isError checks
Result array order mismatchResults are not reorderedIterate with the same index

Real-World Examples

1. Multiple useQuery calls

const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const orders = useQuery({ queryKey: ['orders'], queryFn: fetchOrders })

2. useQueries with an array

const results = useQueries({ queries: ids.map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id) })) })

3. Combine total

combine: (results) => results.reduce((sum, r) => sum + (r.data?.count ?? 0), 0)

4. Combine loading

combine: (results) => results.some((r) => r.isPending)

5. Combine error

combine: (results) => results.some((r) => r.isError)

6. Per-section loading

{query.isPending ? <Spinner /> : <Data data={query.data} />}

7. Per-section error

{query.isError && <Error message={query.error.message} />}

8. Stable combine function

function combineResults(results) { ... }
useQueries({ queries, combine: combineResults })

9. Empty array

useQueries({ queries: [] })    // Returns an empty array

10. Conditional queries

queries: ids.map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id), enabled: !!id }))

Visual

Parallel Query Execution

┌──────────────────────────────────────────────────────────────┐
│  INDEPENDENT QUERIES RUN CONCURRENTLY                        │
│                                                              │
│  Component mounts                                            │
│       │                                                      │
│       ├── usersQuery: GET /api/users ──────────▶             │
│       │                                                      │
│       ├── ordersQuery: GET /api/orders ────────▶             │
│       │                                                      │
│       └── revenueQuery: GET /api/revenue ──────▶             │
│                                                              │
│  Each query resolves independently.                          │
│  The component renders each section as it becomes ready.     │
└──────────────────────────────────────────────────────────────┘

useQueries for Dynamic Counts

┌──────────────────────────────────────────────────────────────┐
│  useQueries ACCEPTS AN ARRAY OF CONFIGURATIONS               │
│                                                              │
│  ids = [1, 2, 3]                                             │
│       │                                                      │
│       ▼                                                      │
│  queries = [                                                 │
│    { queryKey: ['user', 1], queryFn: () => fetchUser(1) },   │
│    { queryKey: ['user', 2], queryFn: () => fetchUser(2) },   │
│    { queryKey: ['user', 3], queryFn: () => fetchUser(3) },   │
│  ]                                                           │
│       │                                                      │
│       ▼                                                      │
│  results = [result1, result2, result3]                       │
│                                                              │
│  The results array has the same length and order as the      │
│  queries array.                                              │
└──────────────────────────────────────────────────────────────┘

Per-Section vs Single Loading State

┌──────────────────────────────────────────────────────────────┐
│  PER-SECTION RENDERING SHOWS DATA AS IT ARRIVES              │
│                                                              │
│  Single loading state:                                       │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Users: loading...                                      │  │
│  │ Orders: loading...                                     │  │
│  │ Revenue: loading...                                    │  │
│  │ → user waits for the slowest query                     │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Per-section state:                                          │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Users: 42          ← ready                             │  │
│  │ Orders: loading...                                     │  │
│  │ Revenue: $1,200    ← ready                             │  │
│  │ → user sees data as soon as it is available            │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Fixed countMultiple useQuery calls
Dynamic countuseQueries
useQueries inputArray of query configurations
useQueries outputArray of results
Aggregatecombine option
Stable combineDefined outside the component
Loading flagresults.some(r => r.isPending)
Error flagresults.some(r => r.isError)
Per-section renderingEach query’s state checked separately

Key takeaways:

  • Parallel queries are independent. Each useQuery call has its own cache key, its own loading state, and its own error state. The queries run concurrently because none of them depends on the others . The component must handle each state separately, or combine them with an aggregate.
  • useQueries is for a dynamic number of queries. When the number of queries depends on a runtime array, useQueries accepts an array of configurations and returns an array of results. Calling useQuery inside a map violates the rules of hooks .
  • The combine option transforms the results array into a single value. It is useful for computing aggregates like the total count or the combined loading and error flags. The function should be stable—defined outside the component or wrapped in useCallback—to avoid recomputing on every render .
  • Per-section rendering is better than a single loading state. The user sees the data that is ready as soon as it is ready, instead of waiting for the slowest query. The per-section approach renders each query’s loading and error state independently.
  • The results array has the same order as the queries array. The component can iterate over the results with the same index as the configurations, which is useful for matching each result to its corresponding item.
  • The combine function should not be defined inline. A new function on every render causes the hook to recompute the combined value on every render, which defeats the purpose of the memoization. The function should be defined at the module level or wrapped in useCallback .

Remember: Parallel queries are the queries that have no dependency on each other. The multiple useQuery calls are the simplest form, and the useQueries hook is the form for a dynamic number of queries. The combine option computes the aggregate, and the per-section rendering shows the data as it arrives. The independent states are the key to the parallel pattern: each query has its own loading, error, and data, and the component decides how to present them. The result is a component that loads its data concurrently and renders it as it becomes available, without blocking the whole page on the slowest request.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!