Skip to content

Commit

Permalink
feat: add typesafe query disabling via skipToken (#6999)
Browse files Browse the repository at this point in the history
* feat: add typesafe react-query disabling via skipToken

* feat: add unique identifier to skip token

* feat(query-core): implement skipToken

* chore(query-core): refactor logic of skipToken

* feat(react-query): do not allow skipToken in useSuspenseQuery

* feat(react-query): handle skip token in suspense react queries, add tests

* refactor(query-core): remove check for queryFn === skipToken, enabled is set to false instead

* refactor(query-core): remove check for queryFn === skipToken, enabled is set to false instead

* chore(react-query): remove unused imports

* fix(react-query): update test to display error message in error boundary

* fix(query-core): reduce bundle size by using Symbol() instead of Symbol.for

* fix(query-core): move check for skipToken into already existing if statement

* refactor(query-core): remove no longer needed check for skipToken

* refactor(query-core): make sure enabled===true takes precendence over skipToken

* refactor(react-query): remove unnecesary check for skipToken

* feat: bring back logging in dev mode

* docs: use `skipToken` in TypeScript examples

* refactor: remove checks that shouldn't be necessary because we now assign `enabled` in `defaultQueryOptions`

* types: adjust useQueries types in non-react adapters

* fix: infiniteQueryBehavior needs to _create_ a function that rejects if we have a skipToken, not reject right away

* refactor: flip condition

* try to fix query options

* fix: make core compilable

* fix: allow skipToken on type level for useSuspenseQuery and warn at runtime

it won't work anyways

* test: dataTag + skipToken

* chore: remove a test that doesn't make much sense

* chore: solid tests

* chore: stabilize test

* rewrite test

* test(react-query): remove not needed doNotExecute from test, test-d.tsx files are not executed anyway

* docs(react-query): add example of skipToken into react-query disabling-queries docs

* docs: update disabling queries examples in Angular, React, and Vue guides

* docs: add description of skipToken to typescript.md

* test(react-query): add unique queryKey to should respect skipToken test

* test(react-query): use helper function instead of string in should respect skipToken test

---------

Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc>
  • Loading branch information
Jaaneek and TkDodo committed Mar 5, 2024
1 parent 5e7b273 commit 77d5b2e
Show file tree
Hide file tree
Showing 25 changed files with 299 additions and 64 deletions.
24 changes: 24 additions & 0 deletions docs/framework/angular/guides/disabling-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,27 @@ export class TodosComponent {
```

[//]: # 'Example2'
[//]: # 'Example3'

```ts
@Component({
selector: 'todos',
template: `
<div>
// 馃殌 applying the filter will enable and execute the query
<filters-form onApply="filter.set" />
<todos-table data="query.data()" />
</div>
`,
})
export class TodosComponent {
filter = signal('')

todosQuery = injectQuery(() => ({
queryKey: ['todos', this.filter()],
queryFn: this.filter ? () => fetchTodos(this.filter()) : skipToken,
}))
}
```

[//]: # 'Example3'
34 changes: 33 additions & 1 deletion docs/framework/react/guides/disabling-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ When `enabled` is `false`:
- The query will not automatically fetch on mount.
- The query will not automatically refetch in the background.
- The query will ignore query client `invalidateQueries` and `refetchQueries` calls that would normally result in the query refetching.
- `refetch` returned from `useQuery` can be used to manually trigger the query to fetch.
- `refetch` returned from `useQuery` can be used to manually trigger the query to fetch. However, it will not work with `skipToken`.

> Typescript users may prefer to use [skipToken](#typesafe-disabling-of-queries-using-skiptoken) as an alternative to `enabled = false`.
[//]: # 'Example'

Expand Down Expand Up @@ -92,3 +94,33 @@ If you are using disabled or lazy queries, you can use the `isLoading` flag inst
`isPending && isFetching`

so it will only be true if the query is currently fetching for the first time.

## Typesafe disabling of queries using `skipToken`

If you are using TypeScript, you can use the `skipToken` to disable a query. This is useful when you want to disable a query based on a condition, but you still want to keep the query to be type safe.

> IMPORTANT: `refetch` from `useQuery` will not work with `skipToken`. Other than that, `skipToken` works the same as `enabled: false`.

[//]: # 'Example3'

```tsx
function Todos() {
const [filter, setFilter] = React.useState<string | undefined>()

const { data } = useQuery({
queryKey: ['todos', filter],
// 猬囷笍 disabled as long as the filter is undefined or empty
queryFn: filter ? () => fetchTodos(filter) : skipToken,
})

return (
<div>
// 馃殌 applying the filter will enable and execute the query
<FiltersForm onApply={setFilter} />
{data && <TodosTable data={data}} />
</div>
)
}
```

[//]: # 'Example3'
5 changes: 5 additions & 0 deletions docs/framework/react/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,8 @@ For tips and tricks around type inference, have a look at [React Query and TypeS
the Community Resources. To find out how to get the best possible type-safety, you can read [Type-safe React Query](./community/tkdodos-blog#19-type-safe-react-query).

[//]: # 'Materials'

## Typesafe disabling of queries using `skipToken`

If you are using TypeScript, you can use the `skipToken` to disable a query. This is useful when you want to disable a query based on a condition, but you still want to keep the query to be type safe.
Read more about it in the [Disabling Queries](./guides/disabling-queries.md) guide.
21 changes: 21 additions & 0 deletions docs/framework/vue/guides/disabling-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,24 @@ const { data } = useQuery({
```

[//]: # 'Example2'
[//]: # 'Example3'

```vue
<script setup>
import { useQuery, skipToken } from '@tanstack/vue-query'
const filter = ref('')
const isEnabled = computed(() => !!filter.value)
const { data } = useQuery({
queryKey: ['todos', filter],
// 猬囷笍 disabled as long as the filter is undefined or empty
queryFn: filter ? () => fetchTodos(filter) : skipToken,
})
</script>
<template>
<span v-if="data">Filter was set and data is here!</span>
</template>
```

[//]: # 'Example3'
1 change: 0 additions & 1 deletion examples/react/algolia/src/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export default function SearchResults({ query = '' }: SearchResultsProps) {
hitsPerPage: 5,
staleTime: 1000 * 30, // 30s
gcTime: 1000 * 60 * 15, // 15m
enabled: !!query,
})

if (!query) return null
Expand Down
11 changes: 5 additions & 6 deletions examples/react/algolia/src/useAlgolia.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery, skipToken } from '@tanstack/react-query'
import { search } from './algolia'

export type UseAlgoliaOptions = {
Expand All @@ -7,7 +7,6 @@ export type UseAlgoliaOptions = {
hitsPerPage?: number
staleTime?: number
gcTime?: number
enabled?: boolean
}

export default function useAlgolia<TData>({
Expand All @@ -16,17 +15,17 @@ export default function useAlgolia<TData>({
hitsPerPage = 10,
staleTime,
gcTime,
enabled,
}: UseAlgoliaOptions) {
const queryInfo = useInfiniteQuery({
queryKey: ['algolia', indexName, query, hitsPerPage],
queryFn: ({ pageParam }) =>
search<TData>({ indexName, query, pageParam, hitsPerPage }),
queryFn: query
? ({ pageParam }) =>
search<TData>({ indexName, query, pageParam, hitsPerPage })
: skipToken,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage?.nextPage,
staleTime,
gcTime,
enabled,
})

const hits = queryInfo.data?.pages.map((page) => page.hits).flat()
Expand Down
10 changes: 7 additions & 3 deletions examples/react/basic-typescript/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
import * as React from 'react'
import ReactDOM from 'react-dom/client'
import axios from 'axios'
import { useQuery, useQueryClient, QueryClient } from '@tanstack/react-query'
import {
QueryClient,
skipToken,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
Expand Down Expand Up @@ -95,8 +100,7 @@ const getPostById = async (id: number): Promise<Post> => {
function usePost(postId: number) {
return useQuery({
queryKey: ['post', postId],
queryFn: () => getPostById(postId),
enabled: !!postId,
queryFn: postId ? () => getPostById(postId) : skipToken,
})
}

Expand Down
12 changes: 7 additions & 5 deletions packages/angular-query-experimental/src/inject-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
QueryKey,
QueryObserverOptions,
QueryObserverResult,
SkipToken,
ThrowOnError,
} from '@tanstack/query-core'

Expand Down Expand Up @@ -52,7 +53,9 @@ type GetOptions<T> =
? QueryObserverOptionsForCreateQueries<TQueryFnData>
: // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided
T extends {
queryFn?: QueryFunction<infer TQueryFnData, infer TQueryKey>
queryFn?:
| QueryFunction<infer TQueryFnData, infer TQueryKey>
| SkipToken
select: (data: any) => infer TData
throwOnError?: ThrowOnError<any, infer TError, any, any>
}
Expand All @@ -63,10 +66,9 @@ type GetOptions<T> =
TQueryKey
>
: T extends {
queryFn?: QueryFunction<
infer TQueryFnData,
infer TQueryKey
>
queryFn?:
| QueryFunction<infer TQueryFnData, infer TQueryKey>
| SkipToken
throwOnError?: ThrowOnError<any, infer TError, any, any>
}
? QueryObserverOptionsForCreateQueries<
Expand Down
3 changes: 2 additions & 1 deletion packages/query-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export {
matchQuery,
matchMutation,
keepPreviousData,
skipToken,
} from './utils'
export type { MutationFilters, QueryFilters, Updater } from './utils'
export type { MutationFilters, QueryFilters, Updater, SkipToken } from './utils'
export { isCancelledError } from './retryer'
export {
dehydrate,
Expand Down
21 changes: 15 additions & 6 deletions packages/query-core/src/infiniteQueryBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addToEnd, addToStart } from './utils'
import { addToEnd, addToStart, skipToken } from './utils'
import type { QueryBehavior } from './query'
import type {
InfiniteData,
Expand Down Expand Up @@ -38,11 +38,20 @@ export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(

// Get query function
const queryFn =
context.options.queryFn ||
(() =>
Promise.reject(
new Error(`Missing queryFn: '${context.options.queryHash}'`),
))
context.options.queryFn && context.options.queryFn !== skipToken
? context.options.queryFn
: () => {
if (process.env.NODE_ENV !== 'production') {
if (context.options.queryFn === skipToken) {
console.error(
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${context.options.queryHash}'`,
)
}
}
return Promise.reject(
new Error(`Missing queryFn: '${context.options.queryHash}'`),
)
}

// Create function to fetch a page
const fetchPage = async (
Expand Down
13 changes: 11 additions & 2 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { noop, replaceData, timeUntilStale } from './utils'
import { noop, replaceData, skipToken, timeUntilStale } from './utils'
import { notifyManager } from './notifyManager'
import { canFetch, createRetryer, isCancelledError } from './retryer'
import { Removable } from './removable'
Expand Down Expand Up @@ -387,11 +387,20 @@ export class Query<

// Create fetch function
const fetchFn = () => {
if (!this.options.queryFn) {
if (process.env.NODE_ENV !== 'production') {
if (this.options.queryFn === skipToken) {
console.error(
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${this.options.queryHash}'`,
)
}
}

if (!this.options.queryFn || this.options.queryFn === skipToken) {
return Promise.reject(
new Error(`Missing queryFn: '${this.options.queryHash}'`),
)
}

this.#abortSignalConsumed = false
if (this.options.persister) {
return this.options.persister(
Expand Down
8 changes: 8 additions & 0 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
hashQueryKeyByOptions,
noop,
partialMatchKey,
skipToken,
} from './utils'
import { QueryCache } from './queryCache'
import { MutationCache } from './mutationCache'
Expand Down Expand Up @@ -537,6 +538,13 @@ export class QueryClient {
defaultedOptions.networkMode = 'offlineFirst'
}

if (
defaultedOptions.enabled !== true &&
defaultedOptions.queryFn === skipToken
) {
defaultedOptions.enabled = false
}

return defaultedOptions as DefaultedQueryObserverOptions<
TQueryFnData,
TError,
Expand Down
10 changes: 10 additions & 0 deletions packages/query-core/src/tests/queryClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
hydrate,
onlineManager,
} from '..'
import { skipToken } from '../utils'
import {
createQueryClient,
mockOnlineManagerIsOnline,
Expand Down Expand Up @@ -1354,6 +1355,7 @@ describe('queryClient', () => {
test('should refetch all active queries', async () => {
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const queryFn1 = vi.fn<Array<unknown>, string>().mockReturnValue('data1')
const queryFn2 = vi.fn<Array<unknown>, string>().mockReturnValue('data2')
const observer1 = new QueryObserver(queryClient, {
Expand All @@ -1366,13 +1368,21 @@ describe('queryClient', () => {
queryFn: queryFn2,
enabled: false,
})
const observer3 = new QueryObserver(queryClient, {
queryKey: key3,
queryFn: skipToken,
})
let didSkipTokenRun = false
observer1.subscribe(() => undefined)
observer2.subscribe(() => undefined)
observer3.subscribe(() => (didSkipTokenRun = true))
await queryClient.resetQueries()
observer3.destroy()
observer2.destroy()
observer1.destroy()
expect(queryFn1).toHaveBeenCalledTimes(2)
expect(queryFn2).toHaveBeenCalledTimes(0)
expect(didSkipTokenRun).toBe(false)
})
})

Expand Down
4 changes: 2 additions & 2 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { MutationState } from './mutation'
import type { FetchDirection, Query, QueryBehavior } from './query'
import type { RetryDelayValue, RetryValue } from './retryer'
import type { QueryFilters, QueryTypeFilter } from './utils'
import type { QueryFilters, QueryTypeFilter, SkipToken } from './utils'
import type { QueryCache } from './queryCache'
import type { MutationCache } from './mutationCache'

Expand Down Expand Up @@ -147,7 +147,7 @@ export interface QueryOptions<
* Setting it to `Infinity` will disable garbage collection.
*/
gcTime?: number
queryFn?: QueryFunction<TQueryFnData, TQueryKey, TPageParam>
queryFn?: QueryFunction<TQueryFnData, TQueryKey, TPageParam> | SkipToken
persister?: QueryPersister<
NoInfer<TQueryFnData>,
NoInfer<TQueryKey>,
Expand Down
3 changes: 3 additions & 0 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,6 @@ export function addToStart<T>(items: Array<T>, item: T, max = 0): Array<T> {
const newItems = [item, ...items]
return max && newItems.length > max ? newItems.slice(0, -1) : newItems
}

export const skipToken = Symbol()
export type SkipToken = typeof skipToken

0 comments on commit 77d5b2e

Please sign in to comment.