Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vue-query-use-query-state.md
Original file line number Diff line number Diff line change
@@ -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
25 changes: 17 additions & 8 deletions docs/framework/vue/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 })
Expand Down
113 changes: 113 additions & 0 deletions packages/vue-query/src/__tests__/useQuery.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,119 @@ 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<boolean>()
expectTypeOf(state.data).toEqualTypeOf<string | undefined>()
expectTypeOf(state.refetch).toBeFunction()
})

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<string>()
expectTypeOf(state.error).toEqualTypeOf<null>()
expectTypeOf(state.isPending).toEqualTypeOf<false>()
expectTypeOf(state.isSuccess).toEqualTypeOf<true>()
}
})

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<string>()
expectTypeOf(state.error).toEqualTypeOf<null>()
}
})

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<undefined>()
expectTypeOf(state.error).toEqualTypeOf<null>()
expectTypeOf(state.isPending).toEqualTypeOf<true>()
}

if (state.isPending) {
expectTypeOf(state.data).toEqualTypeOf<undefined>()
}
})

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<Error>()
expectTypeOf(state.data).toEqualTypeOf<string | undefined>()
expectTypeOf(state.isPending).toEqualTypeOf<false>()
}
})

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 }>()
}
})

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', () => {
it('should have possibly undefined data by default', () => {
const key = createKey()
Expand Down
66 changes: 66 additions & 0 deletions packages/vue-query/src/__tests__/useQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
computed,
isReactive,
isReadonly,
isRef,
isVue3,
onScopeDispose,
reactive,
Expand Down Expand Up @@ -104,6 +105,61 @@ 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.refetch).toBeTypeOf('function')

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 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({
Expand Down Expand Up @@ -334,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)(
Expand Down
57 changes: 53 additions & 4 deletions packages/vue-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ import type { UseQueryOptions } from './queryOptions'
import type { UseInfiniteQueryOptions } from './useInfiniteQuery'
import type { MaybeRefOrGetter } from './types'

/**
* Readonly view of a union that stays a union. A single `Readonly<Union>` collapses the members, which
* drops the `status` / `isSuccess` / `isPending` discriminants.
*/
type ReadonlyQueryState<T> = T extends any
? { readonly [K in keyof T]: T[K] }
Comment thread
DamianOsipiuk marked this conversation as resolved.
Resolved
: 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,
Expand All @@ -49,6 +57,37 @@ export type UseBaseQueryReturnType<
* `true`, in which case it rejects.
*/
suspense: () => Promise<TResult>
/**
* The query result as one readonly reactive object. `data`, `error`, `status`, `isPending`, and the other
* 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`.
* When `status` is `'pending'` (or `isPending` is `true`), `data` is `undefined` and `error` is `null`.
* @example
* ```vue
* <script setup lang="ts">
* import { useQuery } from '@tanstack/vue-query'
*
* const { state } = useQuery({
* queryKey: ['post'],
* queryFn: fetchPost,
* })
*
* if (state.status === 'success') {
* // `state.data` is `Post`, not `Post | undefined`
* console.log(state.data.title)
* }
* </script>
*
* <template>
* <span v-if="state.status === 'pending'">Loading...</span>
* <span v-else-if="state.status === 'error'">Error: {{ state.error.message }}</span>
* <h1 v-else>{{ state.data.title }}</h1>
* </template>
* ```
*/
state: ReadonlyQueryState<TResult>
}

type UseQueryOptionsGeneric<
Expand Down Expand Up @@ -137,6 +176,11 @@ 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,
Expand All @@ -145,6 +189,7 @@ export function useBaseQuery<
unsubscribe()
unsubscribe = observer.subscribe((result) => {
updateState(state, result)
state.refetch = refetch
})
}
},
Expand All @@ -155,6 +200,7 @@ export function useBaseQuery<
const updater = () => {
observer.setOptions(defaultedOptions.value)
updateState(state, observer.getCurrentResult())
state.refetch = refetch
}

watch(defaultedOptions, updater)
Expand All @@ -163,11 +209,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<QueryObserverResult<TData, TError>>(
Expand Down Expand Up @@ -243,6 +291,7 @@ export function useBaseQuery<

object.suspense = suspense
object.refetch = refetch
object.state = readonlyState

return object as UseBaseQueryReturnType<TData, TError>
}
23 changes: 22 additions & 1 deletion packages/vue-query/src/useQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +211,25 @@ export function useQuery<
* </template>
* </template>
* ```
* @example
* `state` keeps `data`, `error`, `status`, and `isPending` on one reactive object, so a `status` check
* narrows `data` and you never read `.value`:
* ```vue
* <script setup lang="ts">
* import { useQuery } from '@tanstack/vue-query'
*
* const { state } = useQuery({
* queryKey: ['post'],
* queryFn: fetchPost,
* })
* </script>
*
* <template>
* <span v-if="state.status === 'pending'">Loading...</span>
* <span v-else-if="state.status === 'error'">Error: {{ state.error.message }}</span>
* <h1 v-else>{{ state.data.title }}</h1>
* </template>
* ```
*/
export function useQuery<
TQueryFnData = unknown,
Expand Down
Loading