Cart rows each call useQuery in a .map — hooks explode or the API melts
A runtime-length list of queries needs useQueries or a batched endpoint, because calling useQuery inside a map breaks the hook rules
What you'll build: a cart (or any dynamic id list) that loads line details safely — useQueries for a runtime-length list, or better a single batched endpoint — without violating the Rules of Hooks or opening N parallel GETs you didn't mean to.
The scenario
Checkout receives productIds: string[] from the cart API (often 1–30 ids). A dev writes:
// ❌ Rules of Hooks + N+1{productIds.map((id) => { const { data } = useQuery({ queryKey: ["product", id], queryFn: () => fetchProduct(id) }); return <Line key={id} product={data} />;})}Strict mode / lint: hooks called from a callback. Someone "fixes" it by making a <Line id={id} /> child that calls useQuery — lint passes. Prod cart with 24 lines fires 24 GETs on every open (connection pool saturation, rate limits, waterfall-ish delay on slow mobiles).
Why it escaped QA: fixtures use 1–2 ids; lint green after the child-component move; nobody counted Network waterfalls on a fat cart.
Walkthrough
Stage 1 — Name both bugs
- Hooks in
.map— conditional/variable hook count breaks the Rules of Hooks. - N independent detail fetches — even with legal hooks, you may want one batch (UI N+1).
Dynamic parallel queries exist for the legal parallel case.
Stage 2 — Reject fake fixes
- Disable the lint — hides a real rules violation.
- Always 30 hardcoded
useQuerycalls — absurd; still N requests. - Serial
enabledchain — turns N into a staircase.
Stage 3 — useQueries or batch
Client-side parallel (when no batch API):
// features/cart/hooks/useCartProducts.tsimport { useQueries } from "@tanstack/react-query";import { productKeys } from "@/features/products/api/productKeys";import { fetchProduct } from "@/features/products/api/productApi";
export function useCartProducts(ids: string[]) { return useQueries({ queries: ids.map((id) => ({ queryKey: productKeys.detail(id), queryFn: ({ signal }: { signal: AbortSignal }) => fetchProduct(id, signal), staleTime: 60_000, })), });}export function CartLines({ ids }: { ids: string[] }) { const queries = useCartProducts(ids); if (queries.some((q) => q.isPending)) return <CartSkeleton />; return ( <ul> {queries.map((q, i) => q.data ? <Line key={ids[i]} product={q.data} /> : null, )} </ul> );}Better — one request:
useQuery({ queryKey: productKeys.batch(ids), queryFn: ({ signal }) => fetchProductsByIds(ids, signal), enabled: ids.length > 0,});Stage 4 — Harden + verify the loop
- Wrap in a custom hook so components don't own key factories.
- Partial failure:
useQuerieslets one id 404 without failing siblings; batch APIs should return per-id errors in-band. - Stable
idsarray reference or sorted copy in the key to avoid refetch thrash.
Verify the loop. Cart with 12 ids: either 1 batch GET or 12 parallel GETs from useQueries — never hooks-in-map errors. Remove one id: query count follows. Lint clean.
Variations
combinein useQueries (v5) — derive a single{ data, isPending }view.- Suspense — not
useSuspenseQueryin a map; prefer batcheduseSuspenseQueryor parallel fixeduseSuspenseQueries. - Prefetch batch in route loader — warm before paint.
- Select/transform per row —
selecton each query options entry. - Empty ids —
enabled: ids.length > 0/ emptyqueries: [].
Trade-offs and common pitfalls
- Hooks inside
.map/ conditions — illegal. - Child
useQuerywithout noticing N — lint ≠ performance. - Unsorted ids in the batch key — same set, different order → duplicate cache entries.
- No
signalforwarding — cancels don't abort HTTP. - Over-fetching details the list endpoint already returned — don't.
- One failure fails the whole batch UX — design partial UI.
- Recreating
queriesarray with new options every render carelessly — usually OK (Query hashes options); don't put unstable functions inline without need. - Using
useQuerieswhen the server offers batch — extra complexity. - Ignoring max browser connections — 24 GETs queue; batch wins.
- Testing only 1-id carts — miss the storm.
When NOT to use useQueries
If you control the API, batch. If the list is fixed length of 2–3 known keys, separate useQuery hooks are clearer. useQueries is for runtime-length parallel reads you cannot collapse.
See also
- Dynamic parallel queries
- Custom hooks
- Request waterfall — UI N+1 note
- Feature-shaped Query modules
References
- TanStack Query — useQueries
- React — Rules of Hooks
Demo source
demos/data-fetching/n-plus-one-usequery-in-map/— illegal map vs useQueries vs batch. (Demo host TBD)