From 5d51700664061cb2b65120a0426ab41b1a99653a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Fri, 8 May 2026 14:12:58 +0100 Subject: [PATCH 1/2] feat(query-core): add simplified query methods --- .changeset/true-cameras-wash.md | 5 + .../src/__tests__/queryClient.test-d.tsx | 176 ++- .../src/__tests__/queryClient.test.tsx | 1187 +++++++++++++++-- packages/query-core/src/queryClient.ts | 107 ++ packages/query-core/src/types.ts | 53 +- 5 files changed, 1413 insertions(+), 115 deletions(-) create mode 100644 .changeset/true-cameras-wash.md diff --git a/.changeset/true-cameras-wash.md b/.changeset/true-cameras-wash.md new file mode 100644 index 00000000000..84fbe3fa981 --- /dev/null +++ b/.changeset/true-cameras-wash.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': minor +--- + +add query and infiniteQuery methods, deprecate old imperative methods diff --git a/packages/query-core/src/__tests__/queryClient.test-d.tsx b/packages/query-core/src/__tests__/queryClient.test-d.tsx index 4cd092ddd64..a88544075b2 100644 --- a/packages/query-core/src/__tests__/queryClient.test-d.tsx +++ b/packages/query-core/src/__tests__/queryClient.test-d.tsx @@ -1,6 +1,7 @@ import { assertType, describe, expectTypeOf, it } from 'vitest' import { queryKey } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' +import { skipToken } from '../utils' import type { MutationFilters, QueryFilters, Updater } from '../utils' import type { Mutation } from '../mutation' import type { Query, QueryState } from '../query' @@ -11,6 +12,7 @@ import type { EnsureQueryDataOptions, FetchInfiniteQueryOptions, InfiniteData, + InfiniteQueryExecuteOptions, MutationOptions, OmitKeyof, QueryKey, @@ -158,7 +160,38 @@ describe('getQueryState', () => { }) }) +describe('fetchQuery', () => { + it('should not allow passing select option', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + // @ts-expect-error `select` is not supported on fetchQuery options + select: (data: string) => data.length, + }, + ]) + }) +}) + describe('fetchInfiniteQuery', () => { + it('should not allow passing select option', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 2, + // @ts-expect-error `select` is not supported on fetchInfiniteQuery options + select: (data) => ({ + pages: data.pages.map( + (x: unknown) => `count: ${(x as { count: number }).count}`, + ), + pageParams: data.pageParams, + }), + }, + ]) + }) + it('should allow passing pages', async () => { const data = await new QueryClient().fetchInfiniteQuery({ queryKey: queryKey(), @@ -171,7 +204,7 @@ describe('fetchInfiniteQuery', () => { expectTypeOf(data).toEqualTypeOf>() }) - it('should not allow passing getNextPageParam without pages', () => { + it('should allow passing getNextPageParam without pages', () => { assertType>([ { queryKey: ['key'], @@ -195,6 +228,105 @@ describe('fetchInfiniteQuery', () => { }) }) +describe('query', () => { + it('should allow passing select option', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + select: (data) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn and enabled false', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: false, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) + + it('should infer select type with skipToken queryFn and enabled true', () => { + const result = new QueryClient().query({ + queryKey: ['key'], + queryFn: skipToken, + enabled: true, + select: (data: string) => data.length, + }) + + expectTypeOf(result).toEqualTypeOf>() + }) +}) + +describe('infiniteQuery', () => { + it('should allow passing select option', () => { + const result = new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 2, + select: (data) => ({ + pages: data.pages.map( + (x) => `count: ${(x as { count: number }).count}`, + ), + }), + }) + + expectTypeOf(result).toEqualTypeOf }>>() + }) + + it('should allow passing pages', async () => { + const result = await new QueryClient().infiniteQuery({ + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + getNextPageParam: () => 1, + initialPageParam: 1, + pages: 5, + }) + + expectTypeOf(result).toEqualTypeOf< + InfiniteData<{ count: number }, number> + >() + }) + + it('should allow passing getNextPageParam without pages', () => { + assertType>([ + { + queryKey: ['key'], + queryFn: () => Promise.resolve({ count: 1 }), + initialPageParam: 1, + getNextPageParam: () => 1, + }, + ]) + }) + + it('should not allow passing pages without getNextPageParam', () => { + assertType>([ + // @ts-expect-error Property 'getNextPageParam' is missing + { + queryKey: ['key'], + queryFn: () => Promise.resolve('string'), + initialPageParam: 1, + pages: 5, + }, + ]) + }) +}) + describe('defaultOptions', () => { it('should have a typed QueryFunctionContext', () => { new QueryClient({ @@ -228,12 +360,27 @@ describe('fully typed usage', () => { // Construct typed arguments // + const infiniteQueryOptions: InfiniteQueryExecuteOptions< + TData, + TError, + InfiniteData + > = { + queryKey: ['key', 'infinite'], + pages: 5, + getNextPageParam: (lastPage) => { + expectTypeOf(lastPage).toEqualTypeOf() + return 0 + }, + initialPageParam: 0, + } + const queryOptions: EnsureQueryDataOptions = { - queryKey: ['key'] as any, + queryKey: ['key', 'query'], } + const fetchInfiniteQueryOptions: FetchInfiniteQueryOptions = { - queryKey: ['key'] as any, + queryKey: ['key', 'infinite'], pages: 5, getNextPageParam: (lastPage) => { expectTypeOf(lastPage).toEqualTypeOf() @@ -241,6 +388,7 @@ describe('fully typed usage', () => { }, initialPageParam: 0, } + const mutationOptions: MutationOptions = {} const queryFilters: QueryFilters> = { @@ -311,11 +459,19 @@ describe('fully typed usage', () => { const fetchedQuery = await queryClient.fetchQuery(queryOptions) expectTypeOf(fetchedQuery).toEqualTypeOf() + const queriedData = await queryClient.query(queryOptions) + expectTypeOf(queriedData).toEqualTypeOf() + queryClient.prefetchQuery(queryOptions) - const infiniteQuery = await queryClient.fetchInfiniteQuery( + const fetchInfiniteQueryResult = await queryClient.fetchInfiniteQuery( fetchInfiniteQueryOptions, ) + expectTypeOf(fetchInfiniteQueryResult).toEqualTypeOf< + InfiniteData + >() + + const infiniteQuery = await queryClient.infiniteQuery(infiniteQueryOptions) expectTypeOf(infiniteQuery).toEqualTypeOf>() const infiniteQueryData = await queryClient.ensureInfiniteQueryData( @@ -450,9 +606,19 @@ describe('fully typed usage', () => { const fetchedQuery = await queryClient.fetchQuery(queryOptions) expectTypeOf(fetchedQuery).toEqualTypeOf() + const queriedData = await queryClient.query(queryOptions) + expectTypeOf(queriedData).toEqualTypeOf() + queryClient.prefetchQuery(queryOptions) - const infiniteQuery = await queryClient.fetchInfiniteQuery( + const fetchInfiniteQueryResult = await queryClient.fetchInfiniteQuery( + fetchInfiniteQueryOptions, + ) + expectTypeOf(fetchInfiniteQueryResult).toEqualTypeOf< + InfiniteData + >() + + const infiniteQuery = await queryClient.infiniteQuery( fetchInfiniteQueryOptions, ) expectTypeOf(infiniteQuery).toEqualTypeOf>() diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index c09db304467..923d113eb54 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -8,11 +8,18 @@ import { dehydrate, focusManager, hydrate, + noop, onlineManager, skipToken, } from '..' import { mockOnlineManagerIsOnline } from './utils' -import type { QueryCache, QueryFunction, QueryObserverOptions } from '..' +import type { + InfiniteData, + Query, + QueryCache, + QueryFunction, + QueryObserverOptions, +} from '..' describe('queryClient', () => { let queryClient: QueryClient @@ -449,6 +456,7 @@ describe('queryClient', () => { }) }) + /** @deprecated */ describe('ensureQueryData', () => { it('should return the cached query data if the query is found', async () => { const key = queryKey() @@ -524,6 +532,100 @@ describe('queryClient', () => { }) }) + describe('query with static staleTime', () => { + it('should return the cached query data if the query is found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + queryClient.setQueryData([key, 'id'], 'bar') + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('bar') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return the cached query data if the query is found and cached query data is falsy', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve(0)) + + queryClient.setQueryData([key, 'id'], null) + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual(null) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should call queryFn and return its results if the query is not found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.query({ + queryKey: [key], + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not fetch when initialData is provided', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.query({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialData: 'initial', + }), + ).resolves.toEqual('initial') + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('supports manual background revalidation via a second query call', async () => { + const key = queryKey() + let value = 'data-1' + const queryFn = vi.fn(() => Promise.resolve(value)) + + await expect( + queryClient.query({ + queryKey: key, + queryFn, + staleTime: 'static', + }), + ).resolves.toEqual('data-1') + expect(queryFn).toHaveBeenCalledTimes(1) + + value = 'data-2' + void queryClient + .query({ + queryKey: key, + queryFn, + staleTime: 0, + }) + .catch(noop) + + await vi.advanceTimersByTimeAsync(0) + + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.getQueryData(key)).toBe('data-2') + }) + }) + + /** @deprecated */ describe('ensureInfiniteQueryData', () => { it('should return the cached query data if the query is found', async () => { const key = queryKey() @@ -584,6 +686,45 @@ describe('queryClient', () => { }) }) + describe('infiniteQuery with static staleTime', () => { + it('should return the cached query data if the query is found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + queryClient.setQueryData([key, 'id'], { + pages: ['bar'], + pageParams: [0], + }) + + await expect( + queryClient.infiniteQuery({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialPageParam: 1, + getNextPageParam: () => undefined, + }), + ).resolves.toEqual({ pages: ['bar'], pageParams: [0] }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should fetch the query and return its results if the query is not found', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + + await expect( + queryClient.infiniteQuery({ + queryKey: [key, 'id'], + queryFn, + staleTime: 'static', + initialPageParam: 1, + getNextPageParam: () => undefined, + }), + ).resolves.toEqual({ pages: ['data'], pageParams: [1] }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + }) + describe('getQueriesData', () => { it('should return the query data for all matched queries', () => { const key1 = queryKey() @@ -615,6 +756,7 @@ describe('queryClient', () => { }) }) + /** @deprecated */ describe('fetchQuery', () => { it('should not type-error with strict query key', async () => { type StrictData = 'data' @@ -789,161 +931,937 @@ describe('queryClient', () => { }) }) - describe('fetchInfiniteQuery', () => { + describe('query', () => { it('should not type-error with strict query key', async () => { - type StrictData = string + type StrictData = 'data' type StrictQueryKey = ['strict', ...ReturnType] const key: StrictQueryKey = ['strict', ...queryKey()] - const data = { - pages: ['data'], - pageParams: [0], - } as const - - const fetchFn: QueryFunction = () => - Promise.resolve(data.pages[0]) + const fetchFn: QueryFunction = () => + Promise.resolve('data') await expect( - queryClient.fetchInfiniteQuery< + queryClient.query< StrictData, any, StrictData, - StrictQueryKey, - number - >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), - ).resolves.toEqual(data) + StrictData, + StrictQueryKey + >({ + queryKey: key, + queryFn: fetchFn, + }), + ).resolves.toEqual('data') }) - it('should return infinite query data', async () => { + // https://github.com/tannerlinsley/react-query/issues/652 + it('should not retry by default', async () => { const key = queryKey() - const result = await queryClient.fetchInfiniteQuery({ - queryKey: key, - initialPageParam: 10, - queryFn: ({ pageParam }) => Number(pageParam), - }) - const result2 = queryClient.getQueryData(key) - - const expected = { - pages: [10], - pageParams: [10], - } - expect(result).toEqual(expected) - expect(result2).toEqual(expected) + await expect( + queryClient.query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + }), + ).rejects.toEqual(new Error('error')) }) - }) - describe('prefetchInfiniteQuery', () => { - it('should not type-error with strict query key', async () => { - type StrictData = 'data' - type StrictQueryKey = ['strict', ...ReturnType] - const key: StrictQueryKey = ['strict', ...queryKey()] + it('should return the cached data on cache hit', async () => { + const key = queryKey() - const fetchFn: QueryFunction = () => - Promise.resolve('data') + const fetchFn = () => Promise.resolve('data') + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + }) + const second = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + }) - await queryClient.prefetchInfiniteQuery< - StrictData, - any, - StrictData, - StrictQueryKey, - number - >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }) + expect(second).toBe(first) + }) - const result = queryClient.getQueryData(key) + it('should throw when disabled and no cached data exists', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('data')) + const errorMsg = `Query is disabled and no cached data is available for key: '${JSON.stringify(key)}'` - expect(result).toEqual({ - pages: ['data'], - pageParams: [0], - }) + await expect( + queryClient.query({ + queryKey: key, + queryFn, + enabled: false, + }), + ).rejects.toThrowError(errorMsg) + + expect(queryFn).not.toHaveBeenCalled() }) - it('should return infinite query data', async () => { + it('should return cached data when disabled and apply select', async () => { const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) - await queryClient.prefetchInfiniteQuery({ + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ queryKey: key, - queryFn: ({ pageParam }) => Number(pageParam), - initialPageParam: 10, + queryFn, + enabled: false, + staleTime: 0, + select: (data) => `${data}-selected`, }) - const result = queryClient.getQueryData(key) - - expect(result).toEqual({ - pages: [10], - pageParams: [10], - }) + expect(result).toBe('cached-data-selected') + expect(queryFn).not.toHaveBeenCalled() }) - it('should prefetch multiple pages', async () => { + it('should throw when skipToken is provided and no cached data exists', async () => { const key = queryKey() + const select = vi.fn((data: unknown) => (data as string).length) - await queryClient.prefetchInfiniteQuery({ - queryKey: key, - queryFn: ({ pageParam }) => String(pageParam), - getNextPageParam: (_lastPage, _pages, lastPageParam) => - lastPageParam + 5, - initialPageParam: 10, - pages: 3, - }) - - const result = queryClient.getQueryData(key) + await expect( + queryClient.query({ + queryKey: key, + queryFn: skipToken, + select, + }), + ).rejects.toThrowError() - expect(result).toEqual({ - pages: ['10', '15', '20'], - pageParams: [10, 15, 20], - }) + expect(select).not.toHaveBeenCalled() }) - it('should stop prefetching if getNextPageParam returns undefined', async () => { + it('should return cached data when skipToken is provided', async () => { const key = queryKey() - let count = 0 - await queryClient.prefetchInfiniteQuery({ + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ queryKey: key, - queryFn: ({ pageParam }) => String(pageParam), - getNextPageParam: (_lastPage, _pages, lastPageParam) => { - count++ - return lastPageParam >= 20 ? undefined : lastPageParam + 5 - }, - initialPageParam: 10, - pages: 5, + queryFn: skipToken, + select: (data: unknown) => (data as string).length, }) - const result = queryClient.getQueryData(key) + expect(result).toBe('cached-data'.length) + }) - expect(result).toEqual({ - pages: ['10', '15', '20'], - pageParams: [10, 15, 20], + it('should return cached data when skipToken and enabled false are both provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { value: 'cached-data' }) + + const result = await queryClient.query({ + queryKey: key, + queryFn: skipToken, + enabled: false, + select: (data: { value: string }) => data.value.toUpperCase(), }) - // this check ensures we're exiting the fetch loop early - expect(count).toBe(3) + expect(result).toBe('CACHED-DATA') }) - }) - - describe('prefetchQuery', () => { - it('should not type-error with strict query key', async () => { - type StrictData = 'data' - type StrictQueryKey = ['strict', ...ReturnType] - const key: StrictQueryKey = ['strict', ...queryKey()] - const fetchFn: QueryFunction = () => - Promise.resolve('data') + it('should throw when enabled is true and skipToken are provided with no cached data', async () => { + await expect( + queryClient.query({ + queryKey: queryKey(), + queryFn: skipToken, + enabled: true, + }), + ).rejects.toThrowError() + }) - await queryClient.prefetchQuery< - StrictData, - any, - StrictData, - StrictQueryKey - >({ queryKey: key, queryFn: fetchFn }) + it('should return cached data when enabled is false and skipToken are provided', async () => { + const key1 = queryKey() + queryClient.setQueryData(key1, { value: 'cached-data' }) - const result = queryClient.getQueryData(key) + const booleanDisabledResult = await queryClient.query({ + queryKey: key1, + queryFn: skipToken, + enabled: false, + select: (data: { value: string }) => data.value.length, + }) - expect(result).toEqual('data') + expect(booleanDisabledResult).toBe('cached-data'.length) }) - it('should return undefined when an error is thrown', async () => { + it('should return cached data when enabled callback returns false even if queryFn would return different data', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) + + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: () => false, + }) + + expect(result).toBe('cached-data') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should fetch when enabled callback returns true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: () => true, + staleTime: 0, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should read from cache with static staleTime even if invalidated', async () => { + const key = queryKey() + + const fetchFn = vi.fn(() => Promise.resolve({ data: 'data' })) + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(first.data).toBe('data') + expect(fetchFn).toHaveBeenCalledTimes(1) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + + const second = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + staleTime: 'static', + }) + + expect(fetchFn).toHaveBeenCalledTimes(1) + + expect(second).toBe(first) + }) + + it('should be able to fetch when garbage collection time is set to 0 and then be removed', async () => { + const key1 = queryKey() + const promise = queryClient.query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + gcTime: 0, + }) + await vi.advanceTimersByTimeAsync(10) + await expect(promise).resolves.toEqual(1) + await vi.advanceTimersByTimeAsync(1) + expect(queryClient.getQueryData(key1)).toEqual(undefined) + }) + + it('should keep a query in cache if garbage collection time is Infinity', async () => { + const key1 = queryKey() + const promise = queryClient.query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + gcTime: Infinity, + }) + await vi.advanceTimersByTimeAsync(10) + const result2 = queryClient.getQueryData(key1) + await expect(promise).resolves.toEqual(1) + expect(result2).toEqual(1) + }) + + it('should not force fetch', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'og') + const fetchFn = () => Promise.resolve('new') + const first = await queryClient.query({ + queryKey: key, + queryFn: fetchFn, + initialData: 'initial', + staleTime: 100, + }) + expect(first).toBe('og') + }) + + it('should only fetch if the data is older then the given stale time', async () => { + const key = queryKey() + + let count = 0 + const queryFn = () => ++count + + queryClient.setQueryData(key, count) + const firstPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 100, + }) + await expect(firstPromise).resolves.toBe(0) + await vi.advanceTimersByTimeAsync(10) + const secondPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(secondPromise).resolves.toBe(1) + const thirdPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(thirdPromise).resolves.toBe(1) + await vi.advanceTimersByTimeAsync(10) + const fourthPromise = queryClient.query({ + queryKey: key, + queryFn, + staleTime: 10, + }) + await expect(fourthPromise).resolves.toBe(2) + }) + + it('should evaluate staleTime when provided as a function', async () => { + const key = queryKey() + const staleTime = vi.fn(() => 0) + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + staleTime, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + expect(staleTime).toHaveBeenCalledTimes(1) + }) + + it('should allow new meta', async () => { + const key = queryKey() + + const first = await queryClient.query({ + queryKey: key, + queryFn: ({ meta }) => Promise.resolve(meta), + meta: { + foo: true, + }, + }) + expect(first).toStrictEqual({ foo: true }) + + const second = await queryClient.query({ + queryKey: key, + queryFn: ({ meta }) => Promise.resolve(meta), + meta: { + foo: false, + }, + }) + expect(second).toStrictEqual({ foo: false }) + }) + + it('should fetch when enabled is true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, 'old-data') + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(() => Promise.resolve('new-data')) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + enabled: true, + staleTime: 0, + }) + + expect(result).toBe('new-data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should propagate errors', async () => { + const key = queryKey() + + await expect( + queryClient.query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + }), + ).rejects.toEqual(new Error('error')) + }) + + it('should apply select when data is fresh in cache', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve('fetched-data')) + + queryClient.setQueryData(key, 'cached-data') + + const result = await queryClient.query({ + queryKey: key, + queryFn, + staleTime: Infinity, + select: (data) => `${data}-selected`, + }) + + expect(result).toBe('cached-data-selected') + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should apply select to freshly fetched data', async () => { + const key = queryKey() + const queryFn = vi.fn(() => Promise.resolve({ value: 'fetched-data' })) + + const result = await queryClient.query({ + queryKey: key, + queryFn, + select: (data) => data.value.toUpperCase(), + }) + + expect(result).toBe('FETCHED-DATA') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + }) + + /** @deprecated */ + describe('fetchInfiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = string + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const data = { + pages: ['data'], + pageParams: [0], + } as const + + const fetchFn: QueryFunction = () => + Promise.resolve(data.pages[0]) + + await expect( + queryClient.fetchInfiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), + ).resolves.toEqual(data) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + const result = await queryClient.fetchInfiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + }) + const result2 = queryClient.getQueryData(key) + + const expected = { + pages: [10], + pageParams: [10], + } + + expect(result).toEqual(expected) + expect(result2).toEqual(expected) + }) + }) + + describe('infiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = string + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const data = { + pages: ['data'], + pageParams: [0], + } as const + + const fetchFn: QueryFunction = () => + Promise.resolve(data.pages[0]) + + await expect( + queryClient.infiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }), + ).resolves.toEqual(data) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + const result = await queryClient.infiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + }) + const result2 = queryClient.getQueryData(key) + + const expected = { + pages: [10], + pageParams: [10], + } + + expect(result).toEqual(expected) + expect(result2).toEqual(expected) + }) + + it('should throw when disabled and no cached data exists', async () => { + const key = queryKey() + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(pageParam), + ) + const errorMsg = `Query is disabled and no cached data is available for key: '${JSON.stringify(key)}'` + + await expect( + queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: false, + }), + ).rejects.toThrow(errorMsg) + + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return cached data when disabled and apply select', async () => { + const key = queryKey() + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(`'fetched-${String(pageParam)}`), + ) + + queryClient.setQueryData(key, { + pages: ['cached-page'], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: false, + select: (data) => data.pages.map((page) => `${page}-selected`), + }) + + expect(result).toEqual(['cached-page-selected']) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should return cached data when skipToken is provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: ['page-1'], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + }) + + expect(result).toEqual({ + pages: ['page-1'], + pageParams: [0], + }) + }) + + it('should throw when skipToken is provided and no cached data exists', async () => { + const key = queryKey() + const select = vi.fn( + (data: { pages: Array }) => data.pages.length, + ) + + await expect( + queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + select, + }), + ).rejects.toThrowError() + + expect(select).not.toHaveBeenCalled() + }) + + it('should throw when enabled is true and skipToken are provided with no cached data', async () => { + await expect( + queryClient.infiniteQuery({ + queryKey: queryKey(), + queryFn: skipToken, + initialPageParam: 0, + enabled: true, + }), + ).rejects.toThrowError() + }) + + it('should return cached data when enabled resolves false and skipToken are provided', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: [{ value: 'cached-page' }], + pageParams: [0], + }) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn: skipToken, + initialPageParam: 0, + enabled: () => false, + select: (data: { pages: Array<{ value: string }> }) => + data.pages[0]?.value.length, + }) + + expect(result).toBe('cached-page'.length) + }) + + it('should fetch when enabled callback returns true and cache is stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: ['old-page'], + pageParams: [0], + }) + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve(`new-page-${String(pageParam)}`), + ) + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + enabled: () => true, + staleTime: 0, + }) + + expect(result).toEqual({ + pages: ['new-page-0'], + pageParams: [0], + }) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should evaluate staleTime callback and refetch when it returns stale', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { + pages: [{ value: 'old-page', staleTime: 0 }], + pageParams: [0], + }) + + await vi.advanceTimersByTimeAsync(1) + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve({ + value: `new-page-${String(pageParam)}`, + staleTime: 0, + }), + ) + const staleTimeSpy = vi.fn() + const staleTime = ( + query: Query< + { value: string; staleTime: number }, + Error, + InfiniteData<{ value: string; staleTime: number }, number> + >, + ) => { + staleTimeSpy() + return query.state.data?.pages[0]?.staleTime ?? 0 + } + + const result = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime, + }) + + expect(result).toEqual({ + pages: [{ value: 'new-page-0', staleTime: 0 }], + pageParams: [0], + }) + expect(staleTimeSpy).toHaveBeenCalled() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should read from cache with static staleTime even if invalidated', async () => { + const key = queryKey() + + const queryFn = vi.fn(({ pageParam }: { pageParam: number }) => + Promise.resolve({ value: `fetched-${String(pageParam)}` }), + ) + const first = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime: 'static', + }) + + expect(first).toEqual({ + pages: [{ value: 'fetched-0' }], + pageParams: [0], + }) + expect(queryFn).toHaveBeenCalledTimes(1) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + + const second = await queryClient.infiniteQuery({ + queryKey: key, + queryFn, + initialPageParam: 0, + staleTime: 'static', + }) + + expect(queryFn).toHaveBeenCalledTimes(1) + expect(second).toBe(first) + }) + + it('should apply select to infinite query data', async () => { + const key = queryKey() + + const result = await queryClient.infiniteQuery({ + queryKey: key, + initialPageParam: 10, + queryFn: ({ pageParam }) => Number(pageParam), + select: (data) => data.pages.map((page) => page * 2), + }) + + expect(result).toEqual([20]) + }) + }) + + /** @deprecated */ + describe('prefetchInfiniteQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient.prefetchInfiniteQuery< + StrictData, + any, + StrictData, + StrictQueryKey, + number + >({ queryKey: key, queryFn: fetchFn, initialPageParam: 0 }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['data'], + pageParams: [0], + }) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => Number(pageParam), + initialPageParam: 10, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: [10], + pageParams: [10], + }) + }) + + it('should prefetch multiple pages', async () => { + const key = queryKey() + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => + lastPageParam + 5, + initialPageParam: 10, + pages: 3, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + }) + + it('should stop prefetching if getNextPageParam returns undefined', async () => { + const key = queryKey() + let count = 0 + + await queryClient.prefetchInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => { + count++ + return lastPageParam >= 20 ? undefined : lastPageParam + 5 + }, + initialPageParam: 10, + pages: 5, + }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + + // this check ensures we're exiting the fetch loop early + expect(count).toBe(3) + }) + }) + + describe('infiniteQuery used for prefetching', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: fetchFn, + initialPageParam: 0, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['data'], + pageParams: [0], + }) + }) + + it('should return infinite query data', async () => { + const key = queryKey() + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => Number(pageParam), + initialPageParam: 10, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: [10], + pageParams: [10], + }) + }) + + it('should prefetch multiple pages', async () => { + const key = queryKey() + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => + lastPageParam + 5, + initialPageParam: 10, + pages: 3, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + }) + + it('should stop prefetching if getNextPageParam returns undefined', async () => { + const key = queryKey() + let count = 0 + + await queryClient + .infiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => String(pageParam), + getNextPageParam: (_lastPage, _pages, lastPageParam) => { + count++ + return lastPageParam >= 20 ? undefined : lastPageParam + 5 + }, + initialPageParam: 10, + pages: 5, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual({ + pages: ['10', '15', '20'], + pageParams: [10, 15, 20], + }) + + // this check ensures we're exiting the fetch loop early + expect(count).toBe(3) + }) + }) + + /** @deprecated */ + describe('prefetchQuery', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient.prefetchQuery< + StrictData, + any, + StrictData, + StrictQueryKey + >({ queryKey: key, queryFn: fetchFn }) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual('data') + }) + + it('should return undefined when an error is thrown', async () => { const key = queryKey() const result = await queryClient.prefetchQuery({ @@ -971,6 +1889,59 @@ describe('queryClient', () => { }) }) + describe('query used for prefetching', () => { + it('should not type-error with strict query key', async () => { + type StrictData = 'data' + type StrictQueryKey = ['strict', ...ReturnType] + const key: StrictQueryKey = ['strict', ...queryKey()] + + const fetchFn: QueryFunction = () => + Promise.resolve('data') + + await queryClient + .query({ + queryKey: key, + queryFn: fetchFn, + }) + .catch(noop) + + const result = queryClient.getQueryData(key) + + expect(result).toEqual('data') + }) + + it('should resolve to undefined when error is caught with noop', async () => { + const key = queryKey() + + const result = await queryClient + .query({ + queryKey: key, + queryFn: (): Promise => { + throw new Error('error') + }, + retry: false, + }) + .catch(noop) + + expect(result).toBeUndefined() + }) + + it('should be garbage collected after gcTime if unused', async () => { + const key = queryKey() + + await queryClient + .query({ + queryKey: key, + queryFn: () => 'data', + gcTime: 10, + }) + .catch(noop) + expect(queryCache.find({ queryKey: key })).toBeDefined() + await vi.advanceTimersByTimeAsync(15) + expect(queryCache.find({ queryKey: key })).not.toBeDefined() + }) + }) + describe('removeQueries', () => { it('should not crash when exact is provided', async () => { const key = queryKey() diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index f448a068f2f..4898ec53661 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -4,6 +4,7 @@ import { hashQueryKeyByOptions, noop, partialMatchKey, + resolveQueryBoolean, resolveStaleTime, skipToken, } from './utils' @@ -24,6 +25,7 @@ import type { InferDataFromTag, InferErrorFromTag, InfiniteData, + InfiniteQueryExecuteOptions, InvalidateOptions, InvalidateQueryFilters, MutationKey, @@ -32,6 +34,7 @@ import type { NoInfer, OmitKeyof, QueryClientConfig, + QueryExecuteOptions, QueryKey, QueryObserverOptions, QueryOptions, @@ -136,6 +139,9 @@ export class QueryClient { .data } + /** + * @deprecated Use queryClient.query({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureQueryData< TQueryFnData, TError = DefaultError, @@ -337,6 +343,72 @@ export class QueryClient { return Promise.all(promises).then(noop) } + async query< + TQueryFnData, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, + >( + options: QueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam + >, + ): Promise { + const defaultedOptions = this.defaultQueryOptions(options) + const disabledErrorMessage = `Query is disabled and no cached data is available for key: '${defaultedOptions.queryHash}'` + + // https://github.com/tannerlinsley/react-query/issues/652 + if (defaultedOptions.retry === undefined) { + defaultedOptions.retry = false + } + + const cachedQuery = this.#queryCache.get( + defaultedOptions.queryHash, + ) + + if ( + typeof defaultedOptions.enabled !== 'function' && + defaultedOptions.enabled === false && + cachedQuery?.state.data === undefined + ) { + throw new Error(disabledErrorMessage) + } + + const query = this.#queryCache.build(this, defaultedOptions) + const isEnabled = + resolveQueryBoolean(defaultedOptions.enabled, query) !== false + + if (!isEnabled && query.state.data === undefined) { + throw new Error(disabledErrorMessage) + } + + const isStale = query.isStaleByTime( + resolveStaleTime(defaultedOptions.staleTime, query), + ) + + const queryData = + isStale && isEnabled + ? await query.fetch(defaultedOptions) + : (query.state.data as TQueryData) + + const select = defaultedOptions.select + + if (select) { + return select(queryData) + } + + return queryData as unknown as TData + } + + /** + * @deprecated Use queryClient.query(options) instead. This method will be removed in the next major version. + */ fetchQuery< TQueryFnData, TError = DefaultError, @@ -368,6 +440,9 @@ export class QueryClient { : Promise.resolve(query.state.data as TData) } + /** + * @deprecated Use queryClient.query(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchQuery< TQueryFnData = unknown, TError = DefaultError, @@ -379,6 +454,32 @@ export class QueryClient { return this.fetchQuery(options).then(noop).catch(noop) } + infiniteQuery< + TQueryFnData, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, + >( + options: InfiniteQueryExecuteOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >, + ): Promise< + Array extends Array> + ? InfiniteData + : TData + > { + options._type = 'infinite' + return this.query(options as any) + } + + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. This method will be removed in the next major version. + */ fetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -398,6 +499,9 @@ export class QueryClient { return this.fetchQuery(options as any) } + /** + * @deprecated Use queryClient.infiniteQuery(options) instead. You can swallow errors with `.catch(noop)`. This method will be removed in the next major version. + */ prefetchInfiniteQuery< TQueryFnData, TError = DefaultError, @@ -416,6 +520,9 @@ export class QueryClient { return this.fetchInfiniteQuery(options).then(noop).catch(noop) } + /** + * @deprecated Use queryClient.infiniteQuery({ ...options, staleTime: 'static' }) instead. This method will be removed in the next major version. + */ ensureInfiniteQueryData< TQueryFnData, TError = DefaultError, diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 6cfe16484e5..6295e4827b6 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -488,6 +488,32 @@ export type DefaultedInfiniteQueryObserverOptions< 'throwOnError' | 'refetchOnReconnect' | 'queryHash' > +export interface QueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> extends WithRequired< + QueryOptions, + 'queryKey' +> { + initialPageParam?: never + /** + * Set this to `false` or a function that returns `false` to disable fetching. + * If cached data exists, it will be returned. + */ + enabled?: QueryBooleanOption + select?: (data: TQueryData) => TData + /** + * The time in milliseconds after data is considered stale. + * If the data is fresh it will be returned from the cache. + */ + staleTime?: StaleTimeFunction +} + +/** @deprecated */ export interface FetchQueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -506,6 +532,7 @@ export interface FetchQueryOptions< staleTime?: StaleTimeFunction } +/** @deprecated */ export interface EnsureQueryDataOptions< TQueryFnData = unknown, TError = DefaultError, @@ -522,6 +549,7 @@ export interface EnsureQueryDataOptions< revalidateIfStale?: boolean } +/** @deprecated */ export type EnsureInfiniteQueryDataOptions< TQueryFnData = unknown, TError = DefaultError, @@ -538,13 +566,34 @@ export type EnsureInfiniteQueryDataOptions< revalidateIfStale?: boolean } -type FetchInfiniteQueryPages = +type InfiniteQueryPages = | { pages?: never } | { pages: number getNextPageParam: GetNextPageParamFunction } +export type InfiniteQueryExecuteOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> = Omit< + QueryExecuteOptions< + TQueryFnData, + TError, + TData, + InfiniteData, + TQueryKey, + TPageParam + >, + 'initialPageParam' +> & + InitialPageParam & + InfiniteQueryPages + +/** @deprecated */ export type FetchInfiniteQueryOptions< TQueryFnData = unknown, TError = DefaultError, @@ -562,7 +611,7 @@ export type FetchInfiniteQueryOptions< 'initialPageParam' > & InitialPageParam & - FetchInfiniteQueryPages + InfiniteQueryPages export interface ResultOptions { throwOnError?: boolean From 6999cfc6962a80cf7728b0cb19143eeb79c25de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Fri, 8 May 2026 17:13:52 +0100 Subject: [PATCH 2/2] docs: add docs for simplfied impertive query methods --- docs/eslint/stable-query-client.md | 2 +- .../angular/guides/paginated-queries.md | 10 +- .../framework/angular/guides/query-options.md | 4 +- docs/framework/angular/typescript.md | 6 +- docs/framework/react/guides/advanced-ssr.md | 68 +++++---- .../react/guides/initial-query-data.md | 4 +- .../framework/react/guides/migrating-to-v5.md | 43 ++++-- docs/framework/react/guides/prefetching.md | 116 +++++++++------ docs/framework/react/guides/query-options.md | 2 +- docs/framework/react/guides/ssr.md | 32 +++-- .../react/reference/infiniteQueryOptions.md | 2 +- .../framework/react/reference/queryOptions.md | 2 +- docs/framework/react/typescript.md | 4 +- docs/framework/solid/guides/prefetching.md | 43 ++++-- docs/framework/solid/guides/query-options.md | 2 +- docs/framework/solid/typescript.md | 4 +- docs/framework/svelte/ssr.md | 16 ++- docs/reference/QueryClient.md | 133 ++---------------- 18 files changed, 239 insertions(+), 254 deletions(-) diff --git a/docs/eslint/stable-query-client.md b/docs/eslint/stable-query-client.md index d100382c364..bf4e05efd91 100644 --- a/docs/eslint/stable-query-client.md +++ b/docs/eslint/stable-query-client.md @@ -51,7 +51,7 @@ function App() { ```tsx async function App() { const queryClient = new QueryClient() - await queryClient.prefetchQuery(options) + await queryClient.query(options) } ``` diff --git a/docs/framework/angular/guides/paginated-queries.md b/docs/framework/angular/guides/paginated-queries.md index 0510cfed253..5c366c258c4 100644 --- a/docs/framework/angular/guides/paginated-queries.md +++ b/docs/framework/angular/guides/paginated-queries.md @@ -83,10 +83,12 @@ export class PaginationExampleComponent { effect(() => { // Prefetch the next page! if (!this.query.isPlaceholderData() && this.query.data()?.hasMore) { - this.#queryClient.prefetchQuery({ - queryKey: ['projects', this.page() + 1], - queryFn: () => lastValueFrom(fetchProjects(this.page() + 1)), - }) + void this.#queryClient + .query({ + queryKey: ['projects', this.page() + 1], + queryFn: () => lastValueFrom(fetchProjects(this.page() + 1)), + }) + .catch(noop) } }) } diff --git a/docs/framework/angular/guides/query-options.md b/docs/framework/angular/guides/query-options.md index d63753bbfc9..ce0e1c078bf 100644 --- a/docs/framework/angular/guides/query-options.md +++ b/docs/framework/angular/guides/query-options.md @@ -7,7 +7,7 @@ ref: docs/framework/react/guides/query-options.md [//]: # 'Example1' ```ts -import { queryOptions } from '@tanstack/angular-query-experimental' +import { queryOptions, noop } from '@tanstack/angular-query-experimental' @Injectable({ providedIn: 'root', @@ -38,7 +38,7 @@ queries = inject(QueriesService) postQuery = injectQuery(() => this.queries.post(this.postId())) -queryClient.prefetchQuery(this.queries.post(23)) +queryClient.query(this.queries.post(23)).catch(noop) queryClient.setQueryData(this.queries.post(42).queryKey, newPost) ``` diff --git a/docs/framework/angular/typescript.md b/docs/framework/angular/typescript.md index de3fd49758e..99781ffa50c 100644 --- a/docs/framework/angular/typescript.md +++ b/docs/framework/angular/typescript.md @@ -174,9 +174,11 @@ computed(() => { ## Typing Query Options -If you inline query options into `injectQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `injectQuery` and e.g. `prefetchQuery` or manage them in a service. In that case, you'd lose type inference. To get it back, you can use the `queryOptions` helper: +If you inline query options into `injectQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `injectQuery` and e.g. `queryClient.query`, or manage them in a service. In that case, you'd lose type inference. To get it back, you can use the `queryOptions` helper: ```ts +import { noop } from '@tanstack/angular-query-experimental' + @Injectable({ providedIn: 'root', }) @@ -215,7 +217,7 @@ export class Component { postQuery = injectQuery(this.optionsSignal) someMethod() { - this.queryClient.prefetchQuery(this.queries.post(23)) + void this.queryClient.query(this.queries.post(23)).catch(noop) } } ``` diff --git a/docs/framework/react/guides/advanced-ssr.md b/docs/framework/react/guides/advanced-ssr.md index dbc70d8995e..3c4e81649fd 100644 --- a/docs/framework/react/guides/advanced-ssr.md +++ b/docs/framework/react/guides/advanced-ssr.md @@ -116,10 +116,12 @@ import { export async function getStaticProps() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return { props: { @@ -172,10 +174,12 @@ import Posts from './posts' export default async function PostsPage() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return ( // Neat! Serialization is now as easy as passing props. @@ -237,10 +241,12 @@ import CommentsServerComponent from './comments-server' export default async function PostsPage() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return ( @@ -261,10 +267,12 @@ import Comments from './comments' export default async function CommentsServerComponent() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts-comments'], - queryFn: getComments, - }) + await queryClient + .query({ + queryKey: ['posts-comments'], + queryFn: getComments, + }) + .catch(noop) return ( @@ -325,8 +333,8 @@ import Posts from './posts' export default async function PostsPage() { const queryClient = new QueryClient() - // Note we are now using fetchQuery() - const posts = await queryClient.fetchQuery({ + // Note we are getting the result from query + const posts = await queryClient.query({ queryKey: ['posts'], queryFn: getPosts, }) @@ -355,7 +363,7 @@ Using React Query with Server Components makes most sense if: It's hard to give general advice on when it makes sense to pair React Query with Server Components and not. **If you are just starting out with a new Server Components app, we suggest you start out with any tools for data fetching your framework provides you with and avoid bringing in React Query until you actually need it.** This might be never, and that's fine, use the right tool for the job! -If you do use it, a good rule of thumb is to avoid `queryClient.fetchQuery` unless you need to catch errors. If you do use it, don't render its result on the server or pass the result to another component, even a Client Component one. +If you do use it, a good rule of thumb is to avoid rendering the result of `queryClient.query` on the server or passing it to another component, even a Client Component one. From the React Query perspective, treat Server Components as a place to prefetch data, nothing more. @@ -424,7 +432,7 @@ export function getQueryClient() { > Note: This works in NextJs and Server Components because React can serialize Promises over the wire when you pass them down to Client Components. -Then, all we need to do is provide a `HydrationBoundary`, but we don't need to `await` prefetches anymore: +Then, all we need to do is provide a `HydrationBoundary`, but we don't need to `await` these prefetches anymore: ```tsx // app/posts/page.tsx @@ -437,10 +445,12 @@ export default function PostsPage() { const queryClient = getQueryClient() // look ma, no await - queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + void queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return ( @@ -504,10 +514,12 @@ export default function PostsPage() { const queryClient = getQueryClient() // look ma, no await - queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server - }) + void queryClient + .query({ + queryKey: ['posts'], + queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server + }) + .catch(noop) return ( diff --git a/docs/framework/react/guides/initial-query-data.md b/docs/framework/react/guides/initial-query-data.md index 971d05af8f8..20ae385e62b 100644 --- a/docs/framework/react/guides/initial-query-data.md +++ b/docs/framework/react/guides/initial-query-data.md @@ -8,7 +8,7 @@ There are many ways to supply initial data for a query to the cache before you n - Declaratively: - Provide `initialData` to a query to prepopulate its cache if empty - Imperatively: - - [Prefetch the data using `queryClient.prefetchQuery`](./prefetching.md) + - [Prefetch the data using `queryClient.query`](./prefetching.md) - [Manually place the data into the cache using `queryClient.setQueryData`](./prefetching.md) ## Using `initialData` to prepopulate a query @@ -84,7 +84,7 @@ By default, `initialData` is treated as totally fresh, as if it were just fetche This option allows the staleTime to be used for its original purpose, determining how fresh the data needs to be, while also allowing the data to be refetched on mount if the `initialData` is older than the `staleTime`. In the example above, our data needs to be fresh within 1 minute, and we can hint to the query when the initialData was last updated so the query can decide for itself whether the data needs to be refetched again or not. - > If you would rather treat your data as **prefetched data**, we recommend that you use the `prefetchQuery` or `fetchQuery` APIs to populate the cache beforehand, thus letting you configure your `staleTime` independently from your initialData + > If you would rather treat your data as **prefetched data**, we recommend that you use the `query` api to populate the cache beforehand, thus letting you configure your `staleTime` independently from your `initialData`. ### Initial Data Function diff --git a/docs/framework/react/guides/migrating-to-v5.md b/docs/framework/react/guides/migrating-to-v5.md index 058a52ccb1b..db2c764ed8b 100644 --- a/docs/framework/react/guides/migrating-to-v5.md +++ b/docs/framework/react/guides/migrating-to-v5.md @@ -29,8 +29,6 @@ useIsMutating({ mutationKey, ...filters }) // [!code ++] ```tsx queryClient.isFetching(key, filters) // [!code --] queryClient.isFetching({ queryKey, ...filters }) // [!code ++] -queryClient.ensureQueryData(key, filters) // [!code --] -queryClient.ensureQueryData({ queryKey, ...filters }) // [!code ++] queryClient.getQueriesData(key, filters) // [!code --] queryClient.getQueriesData({ queryKey, ...filters }) // [!code ++] queryClient.setQueriesData(key, updater, filters, options) // [!code --] @@ -45,14 +43,6 @@ queryClient.invalidateQueries(key, filters, options) // [!code --] queryClient.invalidateQueries({ queryKey, ...filters }, options) // [!code ++] queryClient.refetchQueries(key, filters, options) // [!code --] queryClient.refetchQueries({ queryKey, ...filters }, options) // [!code ++] -queryClient.fetchQuery(key, fn, options) // [!code --] -queryClient.fetchQuery({ queryKey, queryFn, ...options }) // [!code ++] -queryClient.prefetchQuery(key, fn, options) // [!code --] -queryClient.prefetchQuery({ queryKey, queryFn, ...options }) // [!code ++] -queryClient.fetchInfiniteQuery(key, fn, options) // [!code --] -queryClient.fetchInfiniteQuery({ queryKey, queryFn, ...options }) // [!code ++] -queryClient.prefetchInfiniteQuery(key, fn, options) // [!code --] -queryClient.prefetchInfiniteQuery({ queryKey, queryFn, ...options }) // [!code ++] ``` ```tsx @@ -62,6 +52,39 @@ queryCache.findAll(key, filters) // [!code --] queryCache.findAll({ queryKey, ...filters }) // [!code ++] ``` +### Imperative QueryClient methods + +These methods are deprecated as of Tanstack Query `INSERT_FUTURE_V5_MINOR` and will be removed in v6. + +If you are coming from v4 or earlier: + +```tsx +queryClient.fetchQuery(key, fn, options) // [!code --] +queryClient.query({ queryKey: key, queryFn: fn, ...options }) // [!code ++] +queryClient.fetchInfiniteQuery(key, fn, options) // [!code --] +queryClient.infiniteQuery({ + queryKey: key, + queryFn: fn, + ...options, +}) // [!code ++] + +queryClient.prefetchQuery(key, fn, options) // [!code --] +queryClient.query({ queryKey: key, queryFn: fn, ...options }).catch(noop) // [!code ++] + +queryClient.prefetchInfiniteQuery(key, fn, options) // [!code --] +queryClient + .infiniteQuery({ queryKey: key, queryFn: fn, ...options }) + .catch(noop) // [!code ++] + +queryClient.ensureQueryData(key, options) // [!code --] +queryClient.query({ queryKey: key, ...options, staleTime: 'static' }) // [!code ++] + +queryClient.ensureInfiniteQueryData(key, options) // [!code --] +queryClient.infiniteQuery({ queryKey: key, ...options, staleTime: 'static' }) // [!code ++] +``` + +If you are updating older v5 code, It will be the same as the above except for keeping the single options object + ### `queryClient.getQueryData` now accepts queryKey only as an Argument `queryClient.getQueryData` argument is changed to accept only a `queryKey` diff --git a/docs/framework/react/guides/prefetching.md b/docs/framework/react/guides/prefetching.md index c5dde014681..0ee3e6e1b49 100644 --- a/docs/framework/react/guides/prefetching.md +++ b/docs/framework/react/guides/prefetching.md @@ -16,49 +16,65 @@ In this guide, we'll take a look at the first three, while the fourth will be co One specific use of prefetching is to avoid Request Waterfalls, for an in-depth background and explanation of those, see the [Performance & Request Waterfalls guide](./request-waterfalls.md). -## prefetchQuery & prefetchInfiniteQuery +## Using `query` to prefetch -Before jumping into the different specific prefetch patterns, let's look at the `prefetchQuery` and `prefetchInfiniteQuery` functions. First a few basics: +> [!NOTE] +> These tips replace the use of the now deprecated `prefetchQuery` and `ensureQueryData` methods. If you used an earlier version of this guide, note that those methods will be removed in the next major version of TanStack Query -- Out of the box, these functions use the default `staleTime` configured for the `queryClient` to determine whether existing data in the cache is fresh or needs to be fetched again -- You can also pass a specific `staleTime` like this: `prefetchQuery({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })` - - This `staleTime` is only used for the prefetch, you still need to set it for any `useQuery` call as well - - If you want to ignore `staleTime` and instead always return data if it's available in the cache, you can use the `ensureQueryData` function. +Prefetching a query uses the `query` method. This method by default will + +- Run the query function +- Cache the result +- Return the result of that query +- Throw if it hits any errors + +For prefetching you often want to modify these defaults: + +- Out of the box, `query` uses the default `staleTime` configured for the `queryClient` to determine whether existing data in the cache is fresh or needs to be fetched again +- You can also pass a specific `staleTime` like this: `query({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })` + - This `staleTime` is only used for that query fetch, you still need to set it for any `useQuery` call as well + - If you want to always return data if it's available in the cache regardless of the default `staleTime`, you can pass `"static"` in for `staleTime`. - Tip: If you are prefetching on the server, set a default `staleTime` higher than `0` for that `queryClient` to avoid having to pass in a specific `staleTime` to each prefetch call - If no instances of `useQuery` appear for a prefetched query, it will be deleted and garbage collected after the time specified in `gcTime` -- These functions return `Promise` and thus never return query data. If that's something you need, use `fetchQuery`/`fetchInfiniteQuery` instead. -- The prefetch functions never throw errors because they usually try to fetch again in a `useQuery` which is a nice graceful fallback. If you need to catch errors, use `fetchQuery`/`fetchInfiniteQuery` instead. +- If your prefetch is for non-critical data, you can discard the promise with `void` and use `.catch(noop)` to swallow errors. The query will usually try to fetch again in a `useQuery`, which is a nice graceful fallback. -This is how you use `prefetchQuery`: +This is how you use `query` to prefetch: [//]: # 'ExamplePrefetchQuery' ```tsx +import { noop } from '@tanstack/react-query' + const prefetchTodos = async () => { - // The results of this query will be cached like a normal query - await queryClient.prefetchQuery({ - queryKey: ['todos'], - queryFn: fetchTodos, - }) + await queryClient + .query({ + queryKey: ['todos'], + queryFn: fetchTodos, + // Swallow errors here, because usually they will fetch again in `useQuery` + }) + .catch(noop) } ``` [//]: # 'ExamplePrefetchQuery' -Infinite Queries can be prefetched like regular Queries. Per default, only the first page of the Query will be prefetched and will be stored under the given QueryKey. If you want to prefetch more than one page, you can use the `pages` option, in which case you also have to provide a `getNextPageParam` function: +Infinite Queries can be prefetched like regular Queries. By default, only the first page of the Query will be prefetched and will be stored under the given QueryKey. If you want to prefetch more than one page, you can use the `pages` option, in which case you also have to provide a `getNextPageParam` function: [//]: # 'ExamplePrefetchInfiniteQuery' ```tsx -const prefetchProjects = async () => { - // The results of this query will be cached like a normal query - await queryClient.prefetchInfiniteQuery({ - queryKey: ['projects'], - queryFn: fetchProjects, - initialPageParam: 0, - getNextPageParam: (lastPage, pages) => lastPage.nextCursor, - pages: 3, // prefetch the first 3 pages - }) +import { noop } from '@tanstack/react-query' + +const prefetchProjects = () => { + await queryClient + .infiniteQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + initialPageParam: 0, + getNextPageParam: (lastPage, pages) => lastPage.nextCursor, + pages: 3, // prefetch the first 3 pages + }) + .catch(noop) } ``` @@ -68,7 +84,7 @@ Next, let's look at how you can use these and other ways to prefetch in differen ## Prefetch in event handlers -A straightforward form of prefetching is doing it when the user interacts with something. In this example we'll use `queryClient.prefetchQuery` to start a prefetch on `onMouseEnter` or `onFocus`. +A straightforward form of prefetching is doing it when the user interacts with something. In this example we'll use `queryClient.query` to start a prefetch on `onMouseEnter` or `onFocus`. [//]: # 'ExampleEventHandler' @@ -77,13 +93,13 @@ function ShowDetailsButton() { const queryClient = useQueryClient() const prefetch = () => { - queryClient.prefetchQuery({ + void queryClient.query({ queryKey: ['details'], queryFn: getDetailsData, // Prefetch only fires when data is older than the staleTime, // so in a case like this you definitely want to set one staleTime: 60000, - }) + }).catch(noop) } return ( @@ -224,33 +240,37 @@ function Article({ id }) { } ``` -Another way is to prefetch inside of the query function. This makes sense if you know that every time an article is fetched it's very likely comments will also be needed. For this, we'll use `queryClient.prefetchQuery`: +Another way is to prefetch inside the query function. This makes sense if you know that every time an article is fetched it's very likely comments will also be needed. For this, we'll use `queryClient.query`: ```tsx const queryClient = useQueryClient() const { data: articleData, isPending } = useQuery({ queryKey: ['article', id], queryFn: (...args) => { - queryClient.prefetchQuery({ - queryKey: ['article-comments', id], - queryFn: getArticleCommentsById, - }) + void queryClient + .query({ + queryKey: ['article-comments', id], + queryFn: getArticleCommentsById, + }) + .catch(noop) return getArticleById(...args) }, }) ``` -Prefetching in an effect also works, but note that if you are using `useSuspenseQuery` in the same component, this effect wont run until _after_ the query finishes which might not be what you want. +Prefetching in an effect also works, but note that if you are using `useSuspenseQuery` in the same component, this effect won't run until _after_ the query finishes which might not be what you want. ```tsx const queryClient = useQueryClient() useEffect(() => { - queryClient.prefetchQuery({ - queryKey: ['article-comments', id], - queryFn: getArticleCommentsById, - }) + void queryClient + .query({ + queryKey: ['article-comments', id], + queryFn: getArticleCommentsById, + }) + .catch(noop) }, [queryClient, id]) ``` @@ -273,7 +293,7 @@ Sometimes we want to prefetch conditionally, based on the result of another fetc ```tsx // This lazy loads the GraphFeedItem component, meaning -// it wont start loading until something renders it +// it won't start loading until something renders it const GraphFeedItem = React.lazy(() => import('./GraphFeedItem')) function Feed() { @@ -334,10 +354,10 @@ function Feed() { for (const feedItem of feed) { if (feedItem.type === 'GRAPH') { - queryClient.prefetchQuery({ + void queryClient.query({ queryKey: ['graph', feedItem.id], queryFn: getGraphDataById, - }) + }).catch(noop) } } @@ -373,6 +393,8 @@ For now, let's focus on the client side case and look at an example of how you c When integrating at the router level, you can choose to either _block_ rendering of that route until all data is present, or you can start a prefetch but not await the result. That way, you can start rendering the route as soon as possible. You can also mix these two approaches and await some critical data, but start rendering before all the secondary data has finished loading. In this example, we'll configure an `/article` route to not render until the article data has finished loading, as well as start prefetching comments as soon as possible, but not block rendering the route if comments haven't finished loading yet. +Note that many route loaders use error boundaries to trigger error fallbacks. Whereas up to now we have been using `.catch(noop)` to ignore errors for data that will be retried by `useQuery`, for critical data that the route will not work without, you should `await` the promise without `noop` and handle the error in a `try` block or the router's error handling (such as TanStack Router's `errorComponent`). + ```tsx const queryClient = new QueryClient() const routerContext = new RouterContext() @@ -393,11 +415,19 @@ const articleRoute = new Route({ context: { queryClient }, routeContext: { articleQueryOptions, commentsQueryOptions }, }) => { - // Fetch comments asap, but don't block - queryClient.prefetchQuery(commentsQueryOptions) + // Fetch comments asap, but don't block or throw errors + void queryClient.query(commentsQueryOptions).catch(noop) // Don't render the route at all until article has been fetched - await queryClient.prefetchQuery(articleQueryOptions) + // As this is critical data we want the error component to trigger + // as soon as possible if something goes wrong + await queryClient.query({ + ...articleQueryOptions, + // If we have the article loaded already, we don't want to block on + // an extra prefetch; fallback on the default useQuery behavior to + // keep the data fresh + staleTime: 'static' + }) }, component: ({ useRouteContext }) => { const { articleQueryOptions, commentsQueryOptions } = useRouteContext() diff --git a/docs/framework/react/guides/query-options.md b/docs/framework/react/guides/query-options.md index 9a33a2a2154..c994cc0d9be 100644 --- a/docs/framework/react/guides/query-options.md +++ b/docs/framework/react/guides/query-options.md @@ -25,7 +25,7 @@ useSuspenseQuery(groupOptions(5)) useQueries({ queries: [groupOptions(1), groupOptions(2)], }) -queryClient.prefetchQuery(groupOptions(23)) +queryClient.query(groupOptions(23)) queryClient.setQueryData(groupOptions(42).queryKey, newGroups) ``` diff --git a/docs/framework/react/guides/ssr.md b/docs/framework/react/guides/ssr.md index 066f6e94e3d..402f718f7da 100644 --- a/docs/framework/react/guides/ssr.md +++ b/docs/framework/react/guides/ssr.md @@ -170,7 +170,7 @@ Setting up the full hydration solution is straightforward and does not have thes With just a little more setup, you can use a `queryClient` to prefetch queries during a preload phase, pass a serialized version of that `queryClient` to the rendering part of the app and reuse it there. This avoids the drawbacks above. Feel free to skip ahead for full Next.js pages router and Remix examples, but at a general level these are the extra steps: - In the framework loader function, create a `const queryClient = new QueryClient(options)` -- In the loader function, do `await queryClient.prefetchQuery(...)` for each query you want to prefetch +- In the loader function, do `await queryClient.query(...)` for each query you want to prefetch - You want to use `await Promise.all(...)` to fetch the queries in parallel when possible - It's fine to have queries that aren't prefetched. These wont be server rendered, instead they will be fetched on the client after the application is interactive. This can be great for content that are shown only after user interaction, or is far down on the page to avoid blocking more critical content. - From the loader, return `dehydrate(queryClient)`, note that the exact syntax to return this differs between frameworks @@ -228,10 +228,12 @@ import { export async function getStaticProps() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return { props: { @@ -310,10 +312,12 @@ import { export async function loader() { const queryClient = new QueryClient() - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: getPosts, - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: getPosts, + }) + .catch(noop) return json({ dehydratedState: dehydrate(queryClient) }) } @@ -419,13 +423,13 @@ How would we prefetch this so it can be server rendered? Here's an example: export async function getServerSideProps() { const queryClient = new QueryClient() - const user = await queryClient.fetchQuery({ + const user = await queryClient.query({ queryKey: ['user', email], queryFn: getUserByEmail, }) if (user?.userId) { - await queryClient.prefetchQuery({ + await queryClient.query({ queryKey: ['projects', userId], queryFn: getProjectsByUser, }) @@ -443,18 +447,18 @@ This can get more complex of course, but since these loader functions are just J React Query defaults to a graceful degradation strategy. This means: -- `queryClient.prefetchQuery(...)` never throws errors - `dehydrate(...)` only includes successful queries, not failed ones +- We can intentionally ignore the returned promise from `void queryClient.query(...)` and add `.catch(noop)` to swallow any errors, so surrounding loader code will not observe query errors This will lead to any failed queries being retried on the client and that the server rendered output will include loading states instead of the full content. -While a good default, sometimes this is not what you want. When critical content is missing, you might want to respond with a 404 or 500 status code depending on the situation. For these cases, use `queryClient.fetchQuery(...)` instead, which will throw errors when it fails, letting you handle things in a suitable way. +While a good default, sometimes this is not what you want. When critical content is missing, you might want to respond with a 404 or 500 status code depending on the situation. For these cases, use `await queryClient.query(...)` without the noop catch, which will throw errors when it fails, letting you handle things in a suitable way. ```tsx let result try { - result = await queryClient.fetchQuery(...) + result = await queryClient.query(...) } catch (error) { // Handle the error, refer to your framework documentation } diff --git a/docs/framework/react/reference/infiniteQueryOptions.md b/docs/framework/react/reference/infiniteQueryOptions.md index 743f99438ab..5455c2a440f 100644 --- a/docs/framework/react/reference/infiniteQueryOptions.md +++ b/docs/framework/react/reference/infiniteQueryOptions.md @@ -12,7 +12,7 @@ infiniteQueryOptions({ **Options** -You can generally pass everything to `infiniteQueryOptions` that you can also pass to [`useInfiniteQuery`](./useInfiniteQuery.md). Some options will have no effect when then forwarded to a function like `queryClient.prefetchInfiniteQuery`, but TypeScript will still be fine with those excess properties. +You can generally pass everything to `infiniteQueryOptions` that you can also pass to [`useInfiniteQuery`](./useInfiniteQuery.md). These options can be shared across hooks and imperative APIs such as `queryClient.infiniteQuery`. - `queryKey: QueryKey` - **Required** diff --git a/docs/framework/react/reference/queryOptions.md b/docs/framework/react/reference/queryOptions.md index b6c5409372d..f20edf312d3 100644 --- a/docs/framework/react/reference/queryOptions.md +++ b/docs/framework/react/reference/queryOptions.md @@ -12,7 +12,7 @@ queryOptions({ **Options** -You can generally pass everything to `queryOptions` that you can also pass to [`useQuery`](./useQuery.md). Some options will have no effect when then forwarded to a function like `queryClient.prefetchQuery`, but TypeScript will still be fine with those excess properties. +You can generally pass everything to `queryOptions` that you can also pass to [`useQuery`](./useQuery.md). These options can be shared across hooks and imperative APIs such as `queryClient.query`. - `queryKey: QueryKey` - **Required** diff --git a/docs/framework/react/typescript.md b/docs/framework/react/typescript.md index ee15ff855db..975b5c1d977 100644 --- a/docs/framework/react/typescript.md +++ b/docs/framework/react/typescript.md @@ -199,7 +199,7 @@ declare module '@tanstack/react-query' { ## Typing Query Options -If you inline query options into `useQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `useQuery` and e.g. `prefetchQuery`. In that case, you'd lose type inference. To get it back, you can use the `queryOptions` helper: +If you inline query options into `useQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `useQuery` and e.g. `query`. In that case, you'd lose type inference. To get it back, you can use the `queryOptions` helper: ```ts import { queryOptions } from '@tanstack/react-query' @@ -213,7 +213,7 @@ function groupOptions() { } useQuery(groupOptions()) -queryClient.prefetchQuery(groupOptions()) +queryClient.query(groupOptions()) ``` Further, the `queryKey` returned from `queryOptions` knows about the `queryFn` associated with it, and we can leverage that type information to make functions like `queryClient.getQueryData` aware of those types as well: diff --git a/docs/framework/solid/guides/prefetching.md b/docs/framework/solid/guides/prefetching.md index 8c7a7720020..903da8435b2 100644 --- a/docs/framework/solid/guides/prefetching.md +++ b/docs/framework/solid/guides/prefetching.md @@ -2,6 +2,7 @@ id: prefetching title: Prefetching & Router Integration ref: docs/framework/react/guides/prefetching.md +replace: { 'react-query': 'solid-query', 'React': 'Solid' } --- [//]: # 'ExampleComponent' @@ -86,17 +87,19 @@ function Comments(props) { [//]: # 'ExampleParentComponent' [//]: # 'Suspense' -Another way is to prefetch inside of the query function. This makes sense if you know that every time an article is fetched it's very likely comments will also be needed. For this, we'll use `queryClient.prefetchQuery`: +Another way is to prefetch inside the query function. This makes sense if you know that every time an article is fetched it's very likely comments will also be needed. For this, we'll use `queryClient.query`: ```tsx const queryClient = useQueryClient() const articleQuery = useQuery(() => ({ queryKey: ['article', id], queryFn: (...args) => { - queryClient.prefetchQuery({ - queryKey: ['article-comments', id], - queryFn: getArticleCommentsById, - }) + void queryClient + .query({ + queryKey: ['article-comments', id], + queryFn: getArticleCommentsById, + }) + .catch(noop) return getArticleById(...args) }, @@ -111,10 +114,12 @@ import { createEffect } from 'solid-js' const queryClient = useQueryClient() createEffect(() => { - queryClient.prefetchQuery({ - queryKey: ['article-comments', id], - queryFn: getArticleCommentsById, - }) + void queryClient + .query({ + queryKey: ['article-comments', id], + queryFn: getArticleCommentsById, + }) + .catch(noop) }) ``` @@ -185,10 +190,10 @@ function Feed() { for (const feedItem of feed) { if (feedItem.type === 'GRAPH') { - queryClient.prefetchQuery({ + void queryClient.query({ queryKey: ['graph', feedItem.id], queryFn: getGraphDataById, - }) + }).catch(noop) } } @@ -213,6 +218,8 @@ For now, let's focus on the client side case and look at an example of how you c When integrating at the router level, you can choose to either _block_ rendering of that route until all data is present, or you can start a prefetch but not await the result. That way, you can start rendering the route as soon as possible. You can also mix these two approaches and await some critical data, but start rendering before all the secondary data has finished loading. In this example, we'll configure an `/article` route to not render until the article data has finished loading, as well as start prefetching comments as soon as possible, but not block rendering the route if comments haven't finished loading yet. +Note that many route loaders use error boundaries to trigger error fallbacks. Whereas up to now we have been using `.catch(noop)` to ignore errors for data that will be retried by `useQuery`, for critical data that the route will not work without, you should `await` the promise without `noop` and handle the error in a `try` block or the router's error handling (such as TanStack Router's `errorComponent`). + ```tsx const queryClient = new QueryClient() const routerContext = new RouterContext() @@ -233,11 +240,19 @@ const articleRoute = new Route({ context: { queryClient }, routeContext: { articleQueryOptions, commentsQueryOptions }, }) => { - // Fetch comments asap, but don't block - queryClient.prefetchQuery(commentsQueryOptions) + // Fetch comments asap, but don't block or throw errors + void queryClient.query(commentsQueryOptions).catch(noop) // Don't render the route at all until article has been fetched - await queryClient.prefetchQuery(articleQueryOptions) + // As this is critical data we want the error component to trigger + // as soon as possible if something goes wrong + await queryClient.query({ + ...articleQueryOptions, + // If we have the article loaded already, we don't want to block on + // an extra prefetch; fallback on the default useQuery behavior to + // keep the data fresh + staleTime: 'static' + }) }, component: ({ useRouteContext }) => { const { articleQueryOptions, commentsQueryOptions } = useRouteContext() diff --git a/docs/framework/solid/guides/query-options.md b/docs/framework/solid/guides/query-options.md index d5a625962b9..7687bc94b18 100644 --- a/docs/framework/solid/guides/query-options.md +++ b/docs/framework/solid/guides/query-options.md @@ -32,7 +32,7 @@ useQuery(() => groupOptions(1)) useQueries(() => ({ queries: [groupOptions(1), groupOptions(2)], })) -queryClient.prefetchQuery(groupOptions(23)) +queryClient.query(groupOptions(23)) queryClient.setQueryData(groupOptions(42).queryKey, newGroups) ``` diff --git a/docs/framework/solid/typescript.md b/docs/framework/solid/typescript.md index 90a3c8027c8..cf9574a414b 100644 --- a/docs/framework/solid/typescript.md +++ b/docs/framework/solid/typescript.md @@ -173,7 +173,7 @@ declare module '@tanstack/solid-query' { ## Typing Query Options -If you inline query options into `useQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `useQuery` and e.g. `prefetchQuery`. In that case, you'd lose type inference. To get it back, you can use `queryOptions` helper: +If you inline query options into `useQuery`, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between `useQuery` and e.g `query`. In that case, you'd lose type inference. To get it back, you can use `queryOptions` helper: ```ts import { queryOptions } from '@tanstack/solid-query' @@ -187,7 +187,7 @@ function groupOptions() { } useQuery(groupOptions) -queryClient.prefetchQuery(groupOptions()) +queryClient.query(groupOptions()) ``` Further, the `queryKey` returned from `queryOptions` knows about the `queryFn` associated with it, and we can leverage that type information to make functions like `queryClient.getQueryData` aware of those types as well: diff --git a/docs/framework/svelte/ssr.md b/docs/framework/svelte/ssr.md index c9eccca4269..028e2425eab 100644 --- a/docs/framework/svelte/ssr.md +++ b/docs/framework/svelte/ssr.md @@ -7,7 +7,7 @@ title: SSR and SvelteKit SvelteKit defaults to rendering routes with SSR. Because of this, you need to disable the query on the server. Otherwise, your query will continue executing on the server asynchronously, even after the HTML has been sent to the client. -The recommended way to achieve this is to use the `browser` module from SvelteKit in your `QueryClient` object. This will not disable `queryClient.prefetchQuery()`, which is used in one of the solutions below. +The recommended way to achieve this is to use the `browser` module from SvelteKit in your `QueryClient` object. This will not disable `queryClient.query()`, which is used in one of the solutions below. **src/routes/+layout.svelte** @@ -77,7 +77,7 @@ Cons: - If you are calling `createQuery` with the same query in multiple locations, you need to pass `initialData` to all of them - There is no way to know at what time the query was fetched on the server, so `dataUpdatedAt` and determining if the query needs refetching is based on when the page loaded instead -### Using `prefetchQuery` +### Using `query` Svelte Query supports prefetching queries on the server. Using this setup below, you can fetch data and pass it into QueryClientProvider before it is sent to the user's browser. Therefore, this data is already available in the cache, and no initial fetch occurs client-side. @@ -122,10 +122,12 @@ export async function load({ parent, fetch }) { const { queryClient } = await parent() // You need to use the SvelteKit fetch function here - await queryClient.prefetchQuery({ - queryKey: ['posts'], - queryFn: async () => (await fetch('/api/posts')).json(), - }) + await queryClient + .query({ + queryKey: ['posts'], + queryFn: async () => (await fetch('/api/posts')).json(), + }) + .catch(noop) } ``` @@ -135,7 +137,7 @@ export async function load({ parent, fetch }) {