From e3fb85a59ba6caca8533c322c6e18442a41b61f9 Mon Sep 17 00:00:00 2001 From: Damian Osipiuk Date: Fri, 6 Nov 2026 22:01:41 +0100 Subject: [PATCH 1/4] feat(vue-query): return a reactive `state` from `useQuery` so `status === 'success'` narrows `data` without unwrapping refs --- .changeset/vue-query-use-query-state.md | 5 + docs/framework/vue/typescript.md | 25 +++-- .../src/__tests__/useQuery.test-d.ts | 98 +++++++++++++++++++ .../vue-query/src/__tests__/useQuery.test.ts | 38 +++++++ packages/vue-query/src/useBaseQuery.ts | 83 +++++++++++++--- packages/vue-query/src/useQuery.ts | 23 ++++- 6 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 .changeset/vue-query-use-query-state.md diff --git a/.changeset/vue-query-use-query-state.md b/.changeset/vue-query-use-query-state.md new file mode 100644 index 00000000000..f1bfe4b19ae --- /dev/null +++ b/.changeset/vue-query-use-query-state.md @@ -0,0 +1,5 @@ +--- +'@tanstack/vue-query': patch +--- + +feat(vue-query): return a reactive `state` from `useQuery` so `status === 'success'` narrows `data` without unwrapping refs. `state` keeps the status fields and leaves `refetch`, `fetchNextPage`, and `fetchPreviousPage` on the composable return diff --git a/docs/framework/vue/typescript.md b/docs/framework/vue/typescript.md index a660e6e4e3c..5e1659c5509 100644 --- a/docs/framework/vue/typescript.md +++ b/docs/framework/vue/typescript.md @@ -52,6 +52,22 @@ const { data } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) [//]: # 'TypeInference3' [//]: # 'TypeNarrowing' +```tsx +const { state } = useQuery({ + queryKey: ['test'], + queryFn: () => Promise.resolve(5), +}) + +if (state.status === 'success') { + state.data + // ^? const data: number +} +``` + +`state` is one readonly reactive object. `data`, `error`, `status`, `isPending`, and the other result fields are plain values, so you read `state.data` rather than `state.data.value`. Because those fields live on one discriminated union, `status === 'success'` (or `isSuccess`) makes `data` defined, and `status === 'pending'` (or `isPending`) makes `data` `undefined` and `error` `null`. + +Destructuring `useQuery()` directly gives you independent refs, and TypeScript cannot carry a narrowing from one ref to another — `if (isSuccess.value)` leaves `data.value` as `Group[] | undefined`. Wrapping the whole return in `reactive()` flattens those refs back into the same union: + ```tsx const { data, isSuccess } = reactive( useQuery({ @@ -66,14 +82,7 @@ if (isSuccess) { } ``` -[typescript playground](https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgVwM4FMCKz1QJ5wC+cAZlBCHAOQACMAhgHaoMDGA1gPQBuOAtAEcc+KgFgAUKEixEcKOnqsYwbuiKlylKr3RUA3BImsIzeEgAm9BgBo4wVAGVkrVulSp1AXjkKlK9AAUaFjCeAEA2lQwbjBUALq2AQCUcJ4AfHAACpr26AB08qgQADaqAQCsSVWGkiRwAfZOLm6oKQgScJ1wlgwSnJydAHoA-BKEEkA) - -The `reactive()` wrapper is what makes this work: it flattens the result into -plain values, so `isSuccess` and `data` stay part of the same discriminated -union. Destructuring `useQuery()` directly gives you independent refs, and -TypeScript cannot carry a narrowing from one ref to another — `if (isSuccess.value)` -leaves `data.value` as `Group[] | undefined`. Without `reactive()`, narrow the -value ref itself: +You can also narrow the value ref itself: ```tsx const { data } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) diff --git a/packages/vue-query/src/__tests__/useQuery.test-d.ts b/packages/vue-query/src/__tests__/useQuery.test-d.ts index 7f9b6cf7d11..8c99691f35b 100644 --- a/packages/vue-query/src/__tests__/useQuery.test-d.ts +++ b/packages/vue-query/src/__tests__/useQuery.test-d.ts @@ -237,6 +237,104 @@ describe('useQuery', () => { }) }) + describe('state', () => { + it('should read data and isPending without unwrapping refs', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + expectTypeOf(state.status).toEqualTypeOf< + 'pending' | 'error' | 'success' + >() + expectTypeOf(state.isPending).toEqualTypeOf() + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state).not.toHaveProperty('refetch') + }) + + it('should narrow data to TData when status is success', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + if (state.status === 'success') { + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + expectTypeOf(state.isPending).toEqualTypeOf() + expectTypeOf(state.isSuccess).toEqualTypeOf() + } + }) + + it('should narrow data to TData when isSuccess is true', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + if (state.isSuccess) { + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + } + }) + + it('should narrow data to undefined when status is pending', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + if (state.status === 'pending') { + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.error).toEqualTypeOf() + expectTypeOf(state.isPending).toEqualTypeOf() + } + + if (state.isPending) { + expectTypeOf(state.data).toEqualTypeOf() + } + }) + + it('should narrow error when status is error and keep data possibly undefined', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + if (state.status === 'error') { + expectTypeOf(state.error).toEqualTypeOf() + expectTypeOf(state.data).toEqualTypeOf() + expectTypeOf(state.isPending).toEqualTypeOf() + } + }) + + it('should keep data defined on state when initialData is provided', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + }) + + expectTypeOf(state.data).toEqualTypeOf<{ wow: boolean }>() + + if (state.status === 'success') { + expectTypeOf(state.data).toEqualTypeOf<{ wow: boolean }>() + } + }) + }) + describe('Discriminated union return type', () => { it('should have possibly undefined data by default', () => { const key = createKey() diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index fa7f78ce655..adf0792f710 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -3,6 +3,7 @@ import { computed, isReactive, isReadonly, + isRef, isVue3, onScopeDispose, reactive, @@ -104,6 +105,43 @@ describe('useQuery', () => { }) }) + it('should expose data, status, and isPending on state without ref unwrapping', async () => { + const key = createKey() + const query = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'Some data'), + }) + + expect(isRef(query.state)).toBe(false) + expect(isReadonly(query.state)).toBe(true) + expect(query.state.status).toBe('pending') + expect(query.state.isPending).toBe(true) + expect(query.state.data).toBeUndefined() + expect(query.state.error).toBeNull() + + const status = computed(() => query.state.status) + const data = computed(() => query.state.data) + + await vi.advanceTimersByTimeAsync(10) + + expect(query.state.status).toBe('success') + expect(query.state.isPending).toBe(false) + expect(query.state.isSuccess).toBe(true) + expect(query.state.data).toBe('Some data') + expect(query.state.error).toBeNull() + expect(query.data.value).toBe(query.state.data) + expect(query.status.value).toBe(query.state.status) + expect(status.value).toBe('success') + expect(data.value).toBe('Some data') + expect(query.state).not.toHaveProperty('refetch') + + const refetchResult = query.refetch() + await vi.advanceTimersByTimeAsync(10) + await refetchResult + expect(query.state.data).toBe('Some data') + expect(query.state.status).toBe('success') + }) + it('should return pending status initially', () => { const key = createKey() const query = useQuery({ diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index c8051c8d1c3..a3ac8d87696 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -24,9 +24,27 @@ import type { UseQueryOptions } from './queryOptions' import type { UseInfiniteQueryOptions } from './useInfiniteQuery' import type { MaybeRefOrGetter } from './types' +/** + * Action methods stay on the composable return (`refetch`, `fetchNextPage`, `fetchPreviousPage`). + * They are not part of the `status` discriminant, so `state` omits them. + */ +type QueryStateFunctionKey = 'refetch' | 'fetchNextPage' | 'fetchPreviousPage' + +/** + * Readonly view of a union that stays a union. A single `Readonly` collapses the members, which + * drops the `status` / `isSuccess` / `isPending` discriminants. + */ +type ReadonlyQueryState = T extends any + ? { + readonly [ + K in keyof T as K extends QueryStateFunctionKey ? never : K + ]: T[K] + } + : never + /** * The result of the query composables: every property of the observer result wrapped in a `Ref`, except - * the `refetch`, `fetchNextPage`, and `fetchPreviousPage` functions, plus `suspense`. + * the `refetch`, `fetchNextPage`, and `fetchPreviousPage` functions, plus `suspense` and `state`. */ export type UseBaseQueryReturnType< TData, @@ -49,6 +67,38 @@ export type UseBaseQueryReturnType< * `true`, in which case it rejects. */ suspense: () => Promise + /** + * The query result as one readonly reactive object. `data`, `error`, `status`, `isPending`, and the other + * status fields are plain values — read `state.data`, not `state.data.value`. `refetch`, `fetchNextPage`, + * and `fetchPreviousPage` stay on the composable return, next to `state`. + * + * The object is a discriminated union, so a check on `status`, `isSuccess`, `isPending`, or `isError` + * narrows the other fields. When `status` is `'success'` (or `isSuccess` is `true`), `data` is `TData`. + * When `status` is `'pending'` (or `isPending` is `true`), `data` is `undefined` and `error` is `null`. + * @example + * ```vue + * + * + * + * ``` + */ + state: ReadonlyQueryState } type UseQueryOptionsGeneric< @@ -133,6 +183,16 @@ export function useBaseQuery< ? shallowReactive(observer.getCurrentResult()) : reactive(observer.getCurrentResult()) + // `updateState` only writes keys already present on `state`. Drop the action + // methods before the first update so `state` keeps the status fields only. + const exposedFns: Record = {} + for (const key of Object.keys(state)) { + if (typeof state[key as keyof typeof state] === 'function') { + exposedFns[key] = state[key as keyof typeof state] + delete state[key as keyof typeof state] + } + } + let unsubscribe = () => { // noop } @@ -157,18 +217,20 @@ export function useBaseQuery< updateState(state, observer.getCurrentResult()) } + // fix #5910 + const refetch = ( + ...args: Parameters['refetch']> + ) => { + updater() + return observer.refetch(...args) + } + watch(defaultedOptions, updater) onScopeDispose(() => { unsubscribe() }) - // fix #5910 - const refetch = (...args: Parameters<(typeof state)['refetch']>) => { - updater() - return state.refetch(...args) - } - const suspense = () => { return new Promise>( (resolve, reject) => { @@ -235,14 +297,11 @@ export function useBaseQuery< : readonly(state) const object: any = toRefs(readonlyState) - for (const key in state) { - if (typeof state[key as keyof typeof state] === 'function') { - object[key] = state[key as keyof typeof state] - } - } + Object.assign(object, exposedFns) object.suspense = suspense object.refetch = refetch + object.state = readonlyState return object as UseBaseQueryReturnType } diff --git a/packages/vue-query/src/useQuery.ts b/packages/vue-query/src/useQuery.ts index 042a432ac10..3c84faca414 100644 --- a/packages/vue-query/src/useQuery.ts +++ b/packages/vue-query/src/useQuery.ts @@ -90,7 +90,9 @@ export function useQuery< * @param queryClient - Use this to use a custom `QueryClient`. Otherwise, the one provided by `VueQueryPlugin` * will be used. * @returns The current query result. `status` is `pending` if there is no cached data to display, `error` if - * the last fetch attempt failed, or `success` if the query has data to display. + * the last fetch attempt failed, or `success` if the query has data to display. `state` is that result as one + * reactive object — `state.data`, `state.isPending`, and the rest are plain values, and `state.status === 'success'` + * narrows `state.data` to `TData`. * @example * A query key built from a reactive `ref` — the query refetches whenever `postId` changes: * ```vue @@ -209,6 +211,25 @@ export function useQuery< * * * ``` + * @example + * `state` keeps `data`, `error`, `status`, and `isPending` on one reactive object, so a `status` check + * narrows `data` and you never read `.value`: + * ```vue + * + * + * + * ``` */ export function useQuery< TQueryFnData = unknown, From b7cc6b50fa7d099e132d2f42554e941b0ca90ac5 Mon Sep 17 00:00:00 2001 From: Damian Osipiuk Date: Fri, 6 Nov 2026 22:25:03 +0100 Subject: [PATCH 2/4] fix: do not remove functions from narrowable state --- .changeset/vue-query-use-query-state.md | 2 +- .../src/__tests__/useQuery.test-d.ts | 17 ++++++- .../vue-query/src/__tests__/useQuery.test.ts | 20 ++++++++- packages/vue-query/src/useBaseQuery.ts | 45 ++++++------------- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/.changeset/vue-query-use-query-state.md b/.changeset/vue-query-use-query-state.md index f1bfe4b19ae..14fc5726d6b 100644 --- a/.changeset/vue-query-use-query-state.md +++ b/.changeset/vue-query-use-query-state.md @@ -2,4 +2,4 @@ '@tanstack/vue-query': patch --- -feat(vue-query): return a reactive `state` from `useQuery` so `status === 'success'` narrows `data` without unwrapping refs. `state` keeps the status fields and leaves `refetch`, `fetchNextPage`, and `fetchPreviousPage` on the composable return +feat(vue-query): return a reactive `state` from `useQuery` so `status === 'success'` narrows `data` without unwrapping refs diff --git a/packages/vue-query/src/__tests__/useQuery.test-d.ts b/packages/vue-query/src/__tests__/useQuery.test-d.ts index 8c99691f35b..518f9bfec6b 100644 --- a/packages/vue-query/src/__tests__/useQuery.test-d.ts +++ b/packages/vue-query/src/__tests__/useQuery.test-d.ts @@ -251,7 +251,7 @@ describe('useQuery', () => { >() expectTypeOf(state.isPending).toEqualTypeOf() expectTypeOf(state.data).toEqualTypeOf() - expectTypeOf(state).not.toHaveProperty('refetch') + expectTypeOf(state.refetch).toBeFunction() }) it('should narrow data to TData when status is success', () => { @@ -333,6 +333,21 @@ describe('useQuery', () => { expectTypeOf(state.data).toEqualTypeOf<{ wow: boolean }>() } }) + + it('should keep a function returned by select as data', () => { + const key = createKey() + + const { state } = useQuery({ + queryKey: key, + queryFn: () => 'fetched', + initialData: 'initial', + select: (value: string) => () => value, + }) + + if (state.status === 'success') { + expectTypeOf(state.data).toEqualTypeOf<() => string>() + } + }) }) describe('Discriminated union return type', () => { diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index adf0792f710..cb00ddc01d6 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -133,7 +133,7 @@ describe('useQuery', () => { expect(query.status.value).toBe(query.state.status) expect(status.value).toBe('success') expect(data.value).toBe('Some data') - expect(query.state).not.toHaveProperty('refetch') + expect(query.state.refetch).toBeTypeOf('function') const refetchResult = query.refetch() await vi.advanceTimersByTimeAsync(10) @@ -142,6 +142,24 @@ describe('useQuery', () => { expect(query.state.status).toBe('success') }) + it('should keep function-valued data on state when select returns a function', async () => { + const key = createKey() + const { state } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'fetched'), + initialData: 'initial', + select: (value: string) => () => value, + }) + + expect(state.status).toBe('success') + expect(state.data()).toBe('initial') + + await vi.advanceTimersByTimeAsync(10) + + expect(state.status).toBe('success') + expect(state.data()).toBe('fetched') + }) + it('should return pending status initially', () => { const key = createKey() const query = useQuery({ diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index a3ac8d87696..410ddb75e48 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -24,22 +24,12 @@ import type { UseQueryOptions } from './queryOptions' import type { UseInfiniteQueryOptions } from './useInfiniteQuery' import type { MaybeRefOrGetter } from './types' -/** - * Action methods stay on the composable return (`refetch`, `fetchNextPage`, `fetchPreviousPage`). - * They are not part of the `status` discriminant, so `state` omits them. - */ -type QueryStateFunctionKey = 'refetch' | 'fetchNextPage' | 'fetchPreviousPage' - /** * Readonly view of a union that stays a union. A single `Readonly` collapses the members, which * drops the `status` / `isSuccess` / `isPending` discriminants. */ type ReadonlyQueryState = T extends any - ? { - readonly [ - K in keyof T as K extends QueryStateFunctionKey ? never : K - ]: T[K] - } + ? { readonly [K in keyof T]: T[K] } : never /** @@ -69,8 +59,7 @@ export type UseBaseQueryReturnType< suspense: () => Promise /** * The query result as one readonly reactive object. `data`, `error`, `status`, `isPending`, and the other - * status fields are plain values — read `state.data`, not `state.data.value`. `refetch`, `fetchNextPage`, - * and `fetchPreviousPage` stay on the composable return, next to `state`. + * fields are plain values — read `state.data`, not `state.data.value`. * * The object is a discriminated union, so a check on `status`, `isSuccess`, `isPending`, or `isError` * narrows the other fields. When `status` is `'success'` (or `isSuccess` is `true`), `data` is `TData`. @@ -183,16 +172,6 @@ export function useBaseQuery< ? shallowReactive(observer.getCurrentResult()) : reactive(observer.getCurrentResult()) - // `updateState` only writes keys already present on `state`. Drop the action - // methods before the first update so `state` keeps the status fields only. - const exposedFns: Record = {} - for (const key of Object.keys(state)) { - if (typeof state[key as keyof typeof state] === 'function') { - exposedFns[key] = state[key as keyof typeof state] - delete state[key as keyof typeof state] - } - } - let unsubscribe = () => { // noop } @@ -217,20 +196,18 @@ export function useBaseQuery< updateState(state, observer.getCurrentResult()) } - // fix #5910 - const refetch = ( - ...args: Parameters['refetch']> - ) => { - updater() - return observer.refetch(...args) - } - watch(defaultedOptions, updater) onScopeDispose(() => { unsubscribe() }) + // fix #5910 + const refetch = (...args: Parameters<(typeof state)['refetch']>) => { + updater() + return state.refetch(...args) + } + const suspense = () => { return new Promise>( (resolve, reject) => { @@ -297,7 +274,11 @@ export function useBaseQuery< : readonly(state) const object: any = toRefs(readonlyState) - Object.assign(object, exposedFns) + for (const key in state) { + if (typeof state[key as keyof typeof state] === 'function') { + object[key] = state[key as keyof typeof state] + } + } object.suspense = suspense object.refetch = refetch From 7babe80de1f4b7ce1ea49125933a59033021b970 Mon Sep 17 00:00:00 2001 From: Damian Osipiuk Date: Fri, 6 Nov 2026 22:41:43 +0100 Subject: [PATCH 3/4] patch refetch in readonly state --- packages/vue-query/src/__tests__/useQuery.test.ts | 10 ++++++++++ packages/vue-query/src/useBaseQuery.ts | 14 +++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/vue-query/src/__tests__/useQuery.test.ts b/packages/vue-query/src/__tests__/useQuery.test.ts index cb00ddc01d6..b40b1df47d1 100644 --- a/packages/vue-query/src/__tests__/useQuery.test.ts +++ b/packages/vue-query/src/__tests__/useQuery.test.ts @@ -390,6 +390,16 @@ describe('useQuery', () => { queryKey: [...key, 'key12'], }), ) + + keyRef.value = 'key13' + await query.state.refetch() + expect(queryFn).toHaveBeenCalledTimes(3) + expect(queryFn).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + queryKey: [...key, 'key13'], + }), + ) }) it.runIf(isVue3)( diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index 410ddb75e48..53dfd690018 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -176,6 +176,10 @@ export function useBaseQuery< // noop } + // Replaced below with the wrapper. Declared here so the subscriber can put it + // back after `updateState` copies the observer's own `refetch` onto `state`. + let refetch: (typeof state)['refetch'] = (...args) => observer.refetch(...args) + if (client.isRestoring) { watch( client.isRestoring, @@ -184,6 +188,7 @@ export function useBaseQuery< unsubscribe() unsubscribe = observer.subscribe((result) => { updateState(state, result) + state.refetch = refetch }) } }, @@ -194,6 +199,7 @@ export function useBaseQuery< const updater = () => { observer.setOptions(defaultedOptions.value) updateState(state, observer.getCurrentResult()) + state.refetch = refetch } watch(defaultedOptions, updater) @@ -202,11 +208,13 @@ export function useBaseQuery< unsubscribe() }) - // fix #5910 - const refetch = (...args: Parameters<(typeof state)['refetch']>) => { + // fix #5910. Call `observer.refetch`: `state.refetch` is this wrapper, so + // calling it would recurse. + refetch = (...args) => { updater() - return state.refetch(...args) + return observer.refetch(...args) } + state.refetch = refetch const suspense = () => { return new Promise>( From fcb289c71750ac334d54b550ac0a592944a2cd51 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:51:54 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- packages/vue-query/src/useBaseQuery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index 53dfd690018..aa99b67686d 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -178,7 +178,8 @@ export function useBaseQuery< // Replaced below with the wrapper. Declared here so the subscriber can put it // back after `updateState` copies the observer's own `refetch` onto `state`. - let refetch: (typeof state)['refetch'] = (...args) => observer.refetch(...args) + let refetch: (typeof state)['refetch'] = (...args) => + observer.refetch(...args) if (client.isRestoring) { watch(