Skip to content

Commit bb8f37a

Browse files
manudeliclaude
andcommitted
docs: document the select typing caveat for parallel-queries hooks
Inlining a `select` on a query object passed to `useQueries` / `useSuspenseQueries` can't infer its `data` argument from the sibling `queryFn` and falls back to `unknown` — a known TypeScript limitation (#6556). Document the two workarounds (annotate the `select` parameter, or define the query with the `queryOptions` helper) in the React docs, and add type tests guarding the behavior. Scoped to React for an easier review; the other framework adapters can follow the same pattern in separate PRs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4eb0ed9 commit bb8f37a

5 files changed

Lines changed: 351 additions & 0 deletions

File tree

docs/framework/react/guides/parallel-queries.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,8 @@ function App({ users }) {
5656
```
5757

5858
[//]: # 'Example2'
59+
[//]: # 'TypeScriptSelect'
60+
61+
> When using TypeScript, an inline `select` written on a query object passed to `useQueries` can't infer its `data` argument from that same object's `queryFn` — it falls back to `unknown`. Annotate the `select` parameter explicitly, or define the query with the [`queryOptions`](../reference/queryOptions.md) helper, to keep type inference. See [this known limitation](https://github.com/TanStack/query/issues/6556).
62+
63+
[//]: # 'TypeScriptSelect'

docs/framework/react/reference/useQueries.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,53 @@ The `combine` function will only re-run if:
6565
- any of the query results changed
6666

6767
This means that an inlined `combine` function, as shown above, will run on every render. To avoid this, you can wrap the `combine` function in `useCallback`, or extract it to a stable function reference if it doesn't have any dependencies.
68+
69+
## TypeScript: typing the `select` option
70+
71+
Unlike `useQuery`, `useQueries` cannot infer the `data` argument of an _inline_ `select` from its sibling `queryFn`. Because `useQueries` infers the type of the whole `queries` array at once, the `select` parameter of a query object written inline cannot be contextually typed from that same object's `queryFn`, so it falls back to `unknown`. This is a [known TypeScript limitation](https://github.com/TanStack/query/issues/6556).
72+
73+
```tsx
74+
useQueries({
75+
queries: [
76+
{
77+
queryKey: ['post', 1],
78+
queryFn: () => fetchPost(1),
79+
// ❌ `data` is `unknown` here
80+
select: (data) => data.title,
81+
},
82+
],
83+
})
84+
```
85+
86+
There are two supported workarounds:
87+
88+
1. Annotate the `select` parameter explicitly:
89+
90+
```tsx
91+
useQueries({
92+
queries: [
93+
{
94+
queryKey: ['post', 1],
95+
queryFn: () => fetchPost(1),
96+
// ✅ `data` is `Post`
97+
select: (data: Post) => data.title,
98+
},
99+
],
100+
})
101+
```
102+
103+
2. Define the query with the [`queryOptions`](./queryOptions.md) helper, which resolves its types in a single object _before_ it reaches `useQueries`:
104+
105+
```tsx
106+
const postOptions = (id: number) =>
107+
queryOptions({
108+
queryKey: ['post', id],
109+
queryFn: () => fetchPost(id),
110+
// ✅ `data` is `Post`
111+
select: (data) => data.title,
112+
})
113+
114+
useQueries({ queries: [postOptions(1), postOptions(2)] })
115+
```
116+
117+
The same applies to [`useSuspenseQueries`](./useSuspenseQueries.md).

docs/framework/react/reference/useSuspenseQueries.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha
1616
- `enabled`
1717
- `placeholderData`
1818

19+
> The [`select` typing caveat](./useQueries.md#typescript-typing-the-select-option) for `useQueries` applies here as well: annotate the `select` parameter or use the [`queryOptions`](./queryOptions.md) helper to keep type inference.
20+
1921
**Returns**
2022

2123
Same structure as [useQueries](./useQueries.md), except that for each `query`:

packages/react-query/src/__tests__/useQueries.test-d.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,4 +868,151 @@ describe('useQueries', () => {
868868
}
869869
})
870870
})
871+
872+
describe('select', () => {
873+
// Inferring the `select` argument of an *inline* query object from its
874+
// sibling `queryFn` is a known TypeScript limitation, because `useQueries`
875+
// infers its array generic from the argument itself. The two supported
876+
// workarounds are to annotate the `select` parameter, or to define the
877+
// query with the `queryOptions` helper.
878+
// https://github.com/TanStack/query/issues/6556
879+
880+
describe('without queryOptions (inline query object)', () => {
881+
it('leaves the select argument as `unknown` without an annotation', () => {
882+
useQueries({
883+
queries: [
884+
{
885+
queryKey: queryKey(),
886+
queryFn: () => Promise.resolve(1),
887+
select: (data) => {
888+
expectTypeOf(data).toBeUnknown()
889+
// @ts-expect-error `data` is `unknown`, not the expected `number`
890+
return data.toFixed()
891+
},
892+
},
893+
],
894+
})
895+
})
896+
897+
it('infers the result when the select parameter is annotated', () => {
898+
const queryResults = useQueries({
899+
queries: [
900+
{
901+
queryKey: queryKey(),
902+
queryFn: () => Promise.resolve(1),
903+
select: (data: number) => data.toFixed(),
904+
},
905+
],
906+
})
907+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string | undefined>()
908+
})
909+
})
910+
911+
describe('with queryOptions passed directly', () => {
912+
it('without select, infers the queryFn data as the result', () => {
913+
const options = queryOptions({
914+
queryKey: queryKey(),
915+
queryFn: () => Promise.resolve(1),
916+
})
917+
const queryResults = useQueries({ queries: [options] })
918+
expectTypeOf(queryResults[0].data).toEqualTypeOf<number | undefined>()
919+
})
920+
921+
it('with select, infers the select argument and the result', () => {
922+
const options = queryOptions({
923+
queryKey: queryKey(),
924+
queryFn: () => Promise.resolve(1),
925+
select: (data) => {
926+
expectTypeOf(data).toEqualTypeOf<number>()
927+
return data.toFixed()
928+
},
929+
})
930+
const queryResults = useQueries({ queries: [options] })
931+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string | undefined>()
932+
})
933+
934+
it('infers select when a base queryOptions is re-wrapped with queryOptions', () => {
935+
const baseOptions = queryOptions({
936+
queryKey: queryKey(),
937+
queryFn: () => Promise.resolve(1),
938+
})
939+
const withSelect = queryOptions({
940+
...baseOptions,
941+
select: (data) => {
942+
expectTypeOf(data).toEqualTypeOf<number>()
943+
return data.toFixed()
944+
},
945+
})
946+
const queryResults = useQueries({
947+
queries: [withSelect, baseOptions],
948+
})
949+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string | undefined>()
950+
expectTypeOf(queryResults[1].data).toEqualTypeOf<number | undefined>()
951+
})
952+
})
953+
954+
describe('with queryOptions spread into an inline query object', () => {
955+
it('without select in the factory, leaves an unannotated select as `unknown`', () => {
956+
const options = queryOptions({
957+
queryKey: queryKey(),
958+
queryFn: () => Promise.resolve(1),
959+
})
960+
useQueries({
961+
queries: [
962+
// @ts-expect-error the spread select argument is `unknown` without an annotation
963+
{
964+
...options,
965+
select: (data) => {
966+
expectTypeOf(data).toBeUnknown()
967+
return data
968+
},
969+
},
970+
],
971+
})
972+
})
973+
974+
it('without select in the factory, an annotated select compiles', () => {
975+
const options = queryOptions({
976+
queryKey: queryKey(),
977+
queryFn: () => Promise.resolve(1),
978+
})
979+
const queryResults = useQueries({
980+
queries: [{ ...options, select: (data: number) => data.toFixed() }],
981+
})
982+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string | undefined>()
983+
})
984+
985+
it('with select in the factory, leaves an unannotated overriding select as `unknown`', () => {
986+
const options = queryOptions({
987+
queryKey: queryKey(),
988+
queryFn: () => Promise.resolve(1),
989+
select: (data) => data + 1,
990+
})
991+
useQueries({
992+
queries: [
993+
// @ts-expect-error the spread select argument is `unknown` without an annotation
994+
{
995+
...options,
996+
select: (data) => {
997+
expectTypeOf(data).toBeUnknown()
998+
return data
999+
},
1000+
},
1001+
],
1002+
})
1003+
})
1004+
1005+
it('with select in the factory, an annotated overriding select compiles', () => {
1006+
const options = queryOptions({
1007+
queryKey: queryKey(),
1008+
queryFn: () => Promise.resolve(1),
1009+
select: (data) => data + 1,
1010+
})
1011+
const queryResults = useQueries({
1012+
queries: [{ ...options, select: (data: number) => data.toFixed() }],
1013+
})
1014+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string | undefined>()
1015+
})
1016+
})
1017+
})
8711018
})

packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,151 @@ describe('UseSuspenseQueries config object overload', () => {
254254
}),
255255
)
256256
})
257+
258+
describe('select', () => {
259+
// Inferring the `select` argument of an *inline* query object from its
260+
// sibling `queryFn` is a known TypeScript limitation, because
261+
// `useSuspenseQueries` infers its array generic from the argument itself.
262+
// The two supported workarounds are to annotate the `select` parameter, or
263+
// to define the query with the `queryOptions` helper.
264+
// https://github.com/TanStack/query/issues/6556
265+
266+
describe('without queryOptions (inline query object)', () => {
267+
it('leaves the select argument as `unknown` without an annotation', () => {
268+
useSuspenseQueries({
269+
queries: [
270+
{
271+
queryKey: queryKey(),
272+
queryFn: () => Promise.resolve(1),
273+
select: (data) => {
274+
expectTypeOf(data).toBeUnknown()
275+
// @ts-expect-error `data` is `unknown`, not the expected `number`
276+
return data.toFixed()
277+
},
278+
},
279+
],
280+
})
281+
})
282+
283+
it('infers the result when the select parameter is annotated', () => {
284+
const queryResults = useSuspenseQueries({
285+
queries: [
286+
{
287+
queryKey: queryKey(),
288+
queryFn: () => Promise.resolve(1),
289+
select: (data: number) => data.toFixed(),
290+
},
291+
],
292+
})
293+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
294+
})
295+
})
296+
297+
describe('with queryOptions passed directly', () => {
298+
it('without select, infers the queryFn data as the result', () => {
299+
const options = queryOptions({
300+
queryKey: queryKey(),
301+
queryFn: () => Promise.resolve(1),
302+
})
303+
const queryResults = useSuspenseQueries({ queries: [options] })
304+
expectTypeOf(queryResults[0].data).toEqualTypeOf<number>()
305+
})
306+
307+
it('with select, infers the select argument and the result', () => {
308+
const options = queryOptions({
309+
queryKey: queryKey(),
310+
queryFn: () => Promise.resolve(1),
311+
select: (data) => {
312+
expectTypeOf(data).toEqualTypeOf<number>()
313+
return data.toFixed()
314+
},
315+
})
316+
const queryResults = useSuspenseQueries({ queries: [options] })
317+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
318+
})
319+
320+
it('infers select when a base queryOptions is re-wrapped with queryOptions', () => {
321+
const baseOptions = queryOptions({
322+
queryKey: queryKey(),
323+
queryFn: () => Promise.resolve(1),
324+
})
325+
const withSelect = queryOptions({
326+
...baseOptions,
327+
select: (data) => {
328+
expectTypeOf(data).toEqualTypeOf<number>()
329+
return data.toFixed()
330+
},
331+
})
332+
const queryResults = useSuspenseQueries({
333+
queries: [withSelect, baseOptions],
334+
})
335+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
336+
expectTypeOf(queryResults[1].data).toEqualTypeOf<number>()
337+
})
338+
})
339+
340+
describe('with queryOptions spread into an inline query object', () => {
341+
it('without select in the factory, leaves an unannotated select untyped', () => {
342+
const options = queryOptions({
343+
queryKey: queryKey(),
344+
queryFn: () => Promise.resolve(1),
345+
})
346+
useSuspenseQueries({
347+
queries: [
348+
{
349+
...options,
350+
// @ts-expect-error the spread select argument has no inferred type without an annotation
351+
select: (data) => {
352+
expectTypeOf(data).toBeAny()
353+
return data
354+
},
355+
},
356+
],
357+
})
358+
})
359+
360+
it('without select in the factory, an annotated select compiles', () => {
361+
const options = queryOptions({
362+
queryKey: queryKey(),
363+
queryFn: () => Promise.resolve(1),
364+
})
365+
const queryResults = useSuspenseQueries({
366+
queries: [{ ...options, select: (data: number) => data.toFixed() }],
367+
})
368+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
369+
})
370+
371+
it('with select in the factory, leaves an unannotated overriding select untyped', () => {
372+
const options = queryOptions({
373+
queryKey: queryKey(),
374+
queryFn: () => Promise.resolve(1),
375+
select: (data) => data + 1,
376+
})
377+
useSuspenseQueries({
378+
queries: [
379+
{
380+
...options,
381+
// @ts-expect-error the spread select argument has no inferred type without an annotation
382+
select: (data) => {
383+
expectTypeOf(data).toBeAny()
384+
return data
385+
},
386+
},
387+
],
388+
})
389+
})
390+
391+
it('with select in the factory, an annotated overriding select compiles', () => {
392+
const options = queryOptions({
393+
queryKey: queryKey(),
394+
queryFn: () => Promise.resolve(1),
395+
select: (data) => data + 1,
396+
})
397+
const queryResults = useSuspenseQueries({
398+
queries: [{ ...options, select: (data: number) => data.toFixed() }],
399+
})
400+
expectTypeOf(queryResults[0].data).toEqualTypeOf<string>()
401+
})
402+
})
403+
})
257404
})

0 commit comments

Comments
 (0)