diff --git a/docs/config.json b/docs/config.json index 8316f688ed..06892d8d6e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -682,6 +682,10 @@ "label": "usePrefetchQuery", "to": "framework/react/reference/usePrefetchQuery" }, + { + "label": "usePrefetchQueries", + "to": "framework/react/reference/usePrefetchQueries" + }, { "label": "usePrefetchInfiniteQuery", "to": "framework/react/reference/usePrefetchInfiniteQuery" diff --git a/docs/framework/react/guides/prefetching.md b/docs/framework/react/guides/prefetching.md index ccdb5ce9df..0e389f879d 100644 --- a/docs/framework/react/guides/prefetching.md +++ b/docs/framework/react/guides/prefetching.md @@ -12,9 +12,9 @@ There are a few different prefetching patterns: 3. Via router integration 4. During Server Rendering (another form of router integration) -In this guide, we'll take a look at the first three, while the fourth will be covered in depth in the [Server Rendering & Hydration guide](../ssr) and the [Advanced Server Rendering guide](../advanced-ssr). +In this guide, we'll take a look at the first three, while the fourth will be covered in depth in the [Server Rendering & Hydration guide](../guides/ssr) and the [Advanced Server Rendering guide](../guides/advanced-ssr). -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). +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](../guides/request-waterfalls). ## prefetchQuery & prefetchInfiniteQuery @@ -196,7 +196,7 @@ This starts fetching `'article-comments'` immediately and flattens the waterfall [//]: # 'Suspense' -If you want to prefetch together with Suspense, you will have to do things a bit differently. You can't use `useSuspenseQueries` to prefetch, since the prefetch would block the component from rendering. You also can not use `useQuery` for the prefetch, because that wouldn't start the prefetch until after suspenseful query had resolved. For this scenario, you can use the [`usePrefetchQuery`](../../reference/usePrefetchQuery) or the [`usePrefetchInfiniteQuery`](../../reference/usePrefetchInfiniteQuery) hooks available in the library. +If you want to prefetch together with Suspense, you will have to do things a bit differently. You can't use `useSuspenseQueries` to prefetch, since the prefetch would block the component from rendering. You also can not use `useQuery` for the prefetch, because that wouldn't start the prefetch until after suspenseful query had resolved. For this scenario, you can use the [`usePrefetchQuery`](../reference/usePrefetchQuery), [`usePrefetchQueries`](../reference/usePrefetchQueries) or the [`usePrefetchInfiniteQuery`](../reference/usePrefetchInfiniteQuery) hooks available in the library. You can now use `useSuspenseQuery` in the component that actually needs the data. You _might_ want to wrap this later component in its own `<Suspense>` boundary so the "secondary" query we are prefetching does not block rendering of the "primary" data. @@ -256,7 +256,7 @@ useEffect(() => { To recap, if you want to prefetch a query during the component lifecycle, there are a few different ways to do it, pick the one that suits your situation best: -- Prefetch before a suspense boundary using `usePrefetchQuery` or `usePrefetchInfiniteQuery` hooks +- Prefetch before a suspense boundary using `usePrefetchQuery`, `usePrefetchQueries` or `usePrefetchInfiniteQuery` hooks - Use `useQuery` or `useSuspenseQueries` and ignore the result - Prefetch inside the query function - Prefetch in an effect @@ -267,7 +267,7 @@ Let's look at a slightly more advanced case next. ### Dependent Queries & Code Splitting -Sometimes we want to prefetch conditionally, based on the result of another fetch. Consider this example borrowed from the [Performance & Request Waterfalls guide](../request-waterfalls): +Sometimes we want to prefetch conditionally, based on the result of another fetch. Consider this example borrowed from the [Performance & Request Waterfalls guide](../guides/request-waterfalls): [//]: # 'ExampleConditionally1' @@ -367,7 +367,7 @@ There is a tradeoff however, in that the code for `getGraphDataById` is now incl Because data fetching in the component tree itself can easily lead to request waterfalls and the different fixes for that can be cumbersome as they accumulate throughout the application, an attractive way to do prefetching is integrating it at the router level. -In this approach, you explicitly declare for each _route_ what data is going to be needed for that component tree, ahead of time. Because Server Rendering has traditionally needed all data to be loaded before rendering starts, this has been the dominating approach for SSR'd apps for a long time. This is still a common approach and you can read more about it in the [Server Rendering & Hydration guide](../ssr). +In this approach, you explicitly declare for each _route_ what data is going to be needed for that component tree, ahead of time. Because Server Rendering has traditionally needed all data to be loaded before rendering starts, this has been the dominating approach for SSR'd apps for a long time. This is still a common approach and you can read more about it in the [Server Rendering & Hydration guide](../guides/ssr). For now, let's focus on the client side case and look at an example of how you can make this work with [Tanstack Router](https://tanstack.com/router). These examples leave out a lot of setup and boilerplate to stay concise, you can check out a [full React Query example](https://tanstack.com/router/v1/docs/framework/react/examples/basic-react-query-file-based) over in the [Tanstack Router docs](https://tanstack.com/router/v1/docs). @@ -412,13 +412,13 @@ const articleRoute = new Route({ }) ``` -Integration with other routers is also possible, see the [React Router example](../../examples/react-router) for another demonstration. +Integration with other routers is also possible, see the [React Router example](../examples/react-router) for another demonstration. [//]: # 'Router' ## Manually Priming a Query -If you already have the data for your query synchronously available, you don't need to prefetch it. You can just use the [Query Client's `setQueryData` method](../../../../reference/QueryClient/#queryclientsetquerydata) to directly add or update a query's cached result by key. +If you already have the data for your query synchronously available, you don't need to prefetch it. You can just use the [Query Client's `setQueryData` method](../../../reference/QueryClient/#queryclientsetquerydata) to directly add or update a query's cached result by key. [//]: # 'ExampleManualPriming' @@ -433,6 +433,6 @@ queryClient.setQueryData(['todos'], todos) For a deep-dive on how to get data into your Query Cache before you fetch, have a look at [#17: Seeding the Query Cache](../community/tkdodos-blog#17-seeding-the-query-cache) from the Community Resources. -Integrating with Server Side routers and frameworks is very similar to what we just saw, with the addition that the data has to passed from the server to the client to be hydrated into the cache there. To learn how, continue on to the [Server Rendering & Hydration guide](../ssr). +Integrating with Server Side routers and frameworks is very similar to what we just saw, with the addition that the data has to passed from the server to the client to be hydrated into the cache there. To learn how, continue on to the [Server Rendering & Hydration guide](../guides/ssr). [//]: # 'Materials' diff --git a/docs/framework/react/reference/usePrefetchQueries.md b/docs/framework/react/reference/usePrefetchQueries.md new file mode 100644 index 0000000000..8516239a5d --- /dev/null +++ b/docs/framework/react/reference/usePrefetchQueries.md @@ -0,0 +1,40 @@ +--- +id: usePrefetchQueries +title: usePrefetchQueries +--- + +```tsx +const ids = [1, 2, 3] + +const queryOpts = ids.map((id) => ({ + queryKey: ['post', id], + queryFn: () => fetchPost(id), + staleTime: Infinity, +})) + +// parent component +usePrefetchQueries({ + queries: queryOps, +}) + +// child component with suspense +const results = useSuspenseQueries({ + queries: queryOpts, +}) +``` + +**Options** + +The `useQueries` hook accepts an options object with a **queries** key whose value is an array with query option objects identical to the [`usePrefetchQuery` hook](../reference/usePrefetchQuery). Remember that some of them are required as below: + +- `queryKey: QueryKey` + + - **Required** + - The query key to prefetch during render + +- `queryFn: (context: QueryFunctionContext) => Promise<TData>` + - **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information. + +**Returns** + +The `usePrefetchQuery` does not return anything, it should be used just to fire a prefetch during render, before a suspense boundary that wraps a component that uses [`useSuspenseQuery`](../reference/useSuspenseQueries). diff --git a/packages/react-query/src/__tests__/usePrefetchQueries.test-d.tsx b/packages/react-query/src/__tests__/usePrefetchQueries.test-d.tsx new file mode 100644 index 0000000000..05c263207d --- /dev/null +++ b/packages/react-query/src/__tests__/usePrefetchQueries.test-d.tsx @@ -0,0 +1,68 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { usePrefetchQueries } from '..' + +describe('usePrefetchQueries', () => { + it('should return nothing', () => { + const result = usePrefetchQueries({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => Promise.resolve(5), + }, + { + queryKey: ['key2'], + queryFn: () => Promise.resolve('data'), + }, + { + queryKey: ['key3'], + queryFn: () => + Promise.resolve({ + foo: 1, + bar: 'fizzbuzz', + }), + }, + ], + }) + + expectTypeOf(result).toEqualTypeOf<void>() + }) + + it('should not allow refetchInterval, enabled or throwOnError options', () => { + usePrefetchQueries({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => Promise.resolve(5), + // @ts-expect-error TS2345 + refetchInterval: 1000, + }, + ], + }) + + usePrefetchQueries({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => Promise.resolve('data'), + // @ts-expect-error TS2345 + enabled: true, + }, + ], + }) + + usePrefetchQueries({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => + Promise.resolve({ + foo: 1, + bar: 'fizzbuzz', + }), + // @ts-expect-error TS2345 + throwOnError: true, + }, + ], + }) + }) +}) diff --git a/packages/react-query/src/__tests__/usePrefetchQueries.test.tsx b/packages/react-query/src/__tests__/usePrefetchQueries.test.tsx new file mode 100644 index 0000000000..07534bc7c5 --- /dev/null +++ b/packages/react-query/src/__tests__/usePrefetchQueries.test.tsx @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest' +import React from 'react' +import { waitFor } from '@testing-library/react' +import { QueryCache, usePrefetchQueries, useSuspenseQueries } from '..' +import { createQueryClient, queryKey, renderWithClient, sleep } from './utils' + +import type { UseSuspenseQueryOptions } from '..' + +const generateQueryFn = (data: string) => + vi + .fn<(...args: Array<any>) => Promise<string>>() + .mockImplementation(async () => { + await sleep(10) + + return data + }) + +describe('usePrefetchQueries', () => { + const queryCache = new QueryCache() + const queryClient = createQueryClient({ queryCache }) + + function Suspended<TData = unknown>(props: { + queriesOpts: Array< + UseSuspenseQueryOptions<TData, Error, TData, Array<string>> + > + children?: React.ReactNode + }) { + const state = useSuspenseQueries({ + queries: props.queriesOpts, + combine: (results) => results.map((r) => r.data), + }) + + return ( + <div> + <div>data: {state.map((data) => String(data)).join(', ')}</div> + {props.children} + </div> + ) + } + + it('should prefetch queries if query states do not exist', async () => { + const queryOpts1 = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchQuery1'), + } + + const queryOpts2 = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchQuery2'), + } + + const componentQueryOpts1 = { + ...queryOpts1, + queryFn: generateQueryFn('useSuspenseQuery1'), + } + + const componentQueryOpts2 = { + ...queryOpts2, + queryFn: generateQueryFn('useSuspenseQuery2'), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + <React.Suspense fallback="Loading..."> + <Suspended queriesOpts={[componentQueryOpts1, componentQueryOpts2]} /> + </React.Suspense> + ) + } + + const rendered = renderWithClient(queryClient, <App />) + + await waitFor(() => + rendered.getByText('data: prefetchQuery1, prefetchQuery2'), + ) + expect(queryOpts1.queryFn).toHaveBeenCalledTimes(1) + expect(queryOpts2.queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not prefetch queries if query states exist', async () => { + const queryOpts1 = { + queryKey: queryKey(), + queryFn: generateQueryFn('The usePrefetchQueries hook is smart! 1'), + } + + const queryOpts2 = { + queryKey: queryKey(), + queryFn: generateQueryFn('The usePrefetchQueries hook is smart! 2'), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + <React.Suspense fallback="Loading..."> + <Suspended queriesOpts={[queryOpts1, queryOpts2]} /> + </React.Suspense> + ) + } + + await queryClient.fetchQuery(queryOpts1) + await queryClient.fetchQuery(queryOpts2) + queryOpts1.queryFn.mockClear() + queryOpts2.queryFn.mockClear() + + const rendered = renderWithClient(queryClient, <App />) + + expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument() + await waitFor(() => + rendered.getByText( + 'data: The usePrefetchQueries hook is smart! 1, The usePrefetchQueries hook is smart! 2', + ), + ) + expect(queryOpts1.queryFn).not.toHaveBeenCalled() + expect(queryOpts2.queryFn).not.toHaveBeenCalled() + }) + + it('should only prefetch queries that do not exist', async () => { + const queryOpts1 = { + queryKey: queryKey(), + queryFn: generateQueryFn('The usePrefetchQueries hook is smart! 1'), + } + + const queryOpts2 = { + queryKey: queryKey(), + queryFn: generateQueryFn('The usePrefetchQueries hook is smart! 2'), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + <React.Suspense fallback="Loading..."> + <Suspended queriesOpts={[queryOpts1, queryOpts2]} /> + </React.Suspense> + ) + } + + await queryClient.fetchQuery(queryOpts1) + queryOpts1.queryFn.mockClear() + queryOpts2.queryFn.mockClear() + + const rendered = renderWithClient(queryClient, <App />) + + await waitFor(() => + rendered.getByText( + 'data: The usePrefetchQueries hook is smart! 1, The usePrefetchQueries hook is smart! 2', + ), + ) + expect(queryOpts1.queryFn).not.toHaveBeenCalled() + expect(queryOpts2.queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not create an endless loop when using inside a suspense boundary', async () => { + const queryOpts1 = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchedQuery1'), + } + + const queryOpts2 = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchedQuery2'), + } + + function Prefetch({ children }: { children: React.ReactNode }) { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + return <>{children}</> + } + + function App() { + return ( + <React.Suspense> + <Prefetch> + <Suspended queriesOpts={[queryOpts1, queryOpts2]} /> + </Prefetch> + </React.Suspense> + ) + } + + const rendered = renderWithClient(queryClient, <App />) + await waitFor(() => + rendered.getByText('data: prefetchedQuery1, prefetchedQuery2'), + ) + expect(queryOpts1.queryFn).toHaveBeenCalledTimes(1) + expect(queryOpts2.queryFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 5f372f4195..4adfab7807 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -16,6 +16,7 @@ export type { SuspenseQueriesOptions, } from './useSuspenseQueries' export { usePrefetchQuery } from './usePrefetchQuery' +export { usePrefetchQueries } from './usePrefetchQueries' export { usePrefetchInfiniteQuery } from './usePrefetchInfiniteQuery' export { queryOptions } from './queryOptions' export type { diff --git a/packages/react-query/src/usePrefetchQueries.tsx b/packages/react-query/src/usePrefetchQueries.tsx new file mode 100644 index 0000000000..d1b7320d97 --- /dev/null +++ b/packages/react-query/src/usePrefetchQueries.tsx @@ -0,0 +1,110 @@ +import { useQueryClient } from './QueryClientProvider' + +import type { + FetchQueryOptions, + QueryClient, + QueryFunction, + ThrowOnError, +} from '@tanstack/query-core' + +// Avoid TS depth-limit error in case of large array literal +type MAXIMUM_DEPTH = 20 + +// Widen the type of the symbol to enable type inference even if skipToken is not immutable. +type SkipTokenForFetchQuery = symbol + +type GetFetchQueryOptions<T> = + // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } + T extends { + queryFnData: infer TQueryFnData + error?: infer TError + data: infer TData + } + ? FetchQueryOptions<TQueryFnData, TError, TData> + : T extends { queryFnData: infer TQueryFnData; error?: infer TError } + ? FetchQueryOptions<TQueryFnData, TError> + : T extends { data: infer TData; error?: infer TError } + ? FetchQueryOptions<unknown, TError, TData> + : // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData] + T extends [infer TQueryFnData, infer TError, infer TData] + ? FetchQueryOptions<TQueryFnData, TError, TData> + : T extends [infer TQueryFnData, infer TError] + ? FetchQueryOptions<TQueryFnData, TError> + : T extends [infer TQueryFnData] + ? FetchQueryOptions<TQueryFnData> + : // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided + T extends { + queryFn?: + | QueryFunction<infer TQueryFnData, infer TQueryKey> + | SkipTokenForFetchQuery + select?: (data: any) => infer TData + throwOnError?: ThrowOnError<any, infer TError, any, any> + } + ? FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey> + : T extends { + queryFn?: + | QueryFunction<infer TQueryFnData, infer TQueryKey> + | SkipTokenForFetchQuery + throwOnError?: ThrowOnError<any, infer TError, any, any> + } + ? FetchQueryOptions< + TQueryFnData, + TError, + TQueryFnData, + TQueryKey + > + : // Fallback + FetchQueryOptions + +/** + * PrefetchQueriesOptions reducer recursively unwraps function arguments to infer/enforce type param + */ +export type PrefetchQueriesOptions< + T extends Array<any>, + TResults extends Array<any> = [], + TDepth extends ReadonlyArray<number> = [], +> = TDepth['length'] extends MAXIMUM_DEPTH + ? Array<FetchQueryOptions> + : T extends [] + ? [] + : T extends [infer Head] + ? [...TResults, GetFetchQueryOptions<Head>] + : T extends [infer Head, ...infer Tails] + ? PrefetchQueriesOptions< + [...Tails], + [...TResults, GetFetchQueryOptions<Head>], + [...TDepth, 1] + > + : Array<unknown> extends T + ? T + : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type! + // use this to infer the param types in the case of Array.map() argument + T extends Array< + FetchQueryOptions< + infer TQueryFnData, + infer TError, + infer TData, + infer TQueryKey + > + > + ? Array<FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>> + : // Fallback + Array<FetchQueryOptions> + +export function usePrefetchQueries<T extends Array<any>>( + options: { + queries: + | readonly [...PrefetchQueriesOptions<T>] + | readonly [...{ [K in keyof T]: GetFetchQueryOptions<T[K]> }] + }, + queryClient?: QueryClient, +) { + const client = useQueryClient(queryClient) + const queries = options.queries as ReadonlyArray<FetchQueryOptions> + + for (const query of queries) { + if (!client.getQueryState(query.queryKey)) { + client.prefetchQuery(query) + } + } +}