diff --git a/docs/config.json b/docs/config.json index e09f9d1de9..0e42813e7e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -823,6 +823,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 fab1a2f683..5b6f06e948 100644 --- a/docs/framework/react/guides/prefetching.md +++ b/docs/framework/react/guides/prefetching.md @@ -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.md) or the [`usePrefetchInfiniteQuery`](../reference/usePrefetchInfiniteQuery.md) 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.md), [`usePrefetchQueries`](../reference/usePrefetchQueries.md) or the [`usePrefetchInfiniteQuery`](../reference/usePrefetchInfiniteQuery.md) 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 `` 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 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` + - **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() + }) + + 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..4a306414e4 --- /dev/null +++ b/packages/react-query/src/__tests__/usePrefetchQueries.test.tsx @@ -0,0 +1,184 @@ +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: T) => + vi.fn<(...args: Array) => Promise>().mockImplementation(async () => { + await sleep(10) + + return data + }) + +describe('usePrefetchQueries', () => { + const queryCache = new QueryCache() + const queryClient = createQueryClient({ queryCache }) + + function Suspended(props: { + queriesOpts: Array + children?: React.ReactNode + }) { + const state = useSuspenseQueries({ + queries: props.queriesOpts, + combine: (results) => results.map((r) => r.data), + }) + + return ( +
+
data: {state.map((data) => String(data)).join(', ')}
+ {props.children} +
+ ) + } + + it('should prefetch queries if query states do not exist', async () => { + const queryOpts1 = { + queryKey: queryKey(), + queryFn: generateQueryFn('prefetchQuery1'), + } + + const queryOpts2 = { + queryKey: queryKey(), + queryFn: generateQueryFn(2), + } + + const componentQueryOpts1 = { + ...queryOpts1, + queryFn: generateQueryFn('useSuspenseQuery1'), + } + + const componentQueryOpts2 = { + ...queryOpts2, + queryFn: generateQueryFn(2), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => rendered.getByText('data: prefetchQuery1, 2')) + 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(2), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + + + + ) + } + + await queryClient.fetchQuery(queryOpts1) + await queryClient.fetchQuery(queryOpts2) + queryOpts1.queryFn.mockClear() + queryOpts2.queryFn.mockClear() + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument() + await waitFor(() => + rendered.getByText('data: The usePrefetchQueries hook is smart! 1, 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(2), + } + + function App() { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + + return ( + + + + ) + } + + await queryClient.fetchQuery(queryOpts1) + queryOpts1.queryFn.mockClear() + queryOpts2.queryFn.mockClear() + + const rendered = renderWithClient(queryClient, ) + + await waitFor(() => + rendered.getByText('data: The usePrefetchQueries hook is smart! 1, 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(2), + } + + function Prefetch({ children }: { children: React.ReactNode }) { + usePrefetchQueries({ + queries: [queryOpts1, queryOpts2], + }) + return <>{children} + } + + function App() { + return ( + + + + + + ) + } + + const rendered = renderWithClient(queryClient, ) + await waitFor(() => rendered.getByText('data: prefetchedQuery1, 2')) + 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..c967752e1e --- /dev/null +++ b/packages/react-query/src/usePrefetchQueries.tsx @@ -0,0 +1,18 @@ +import { useQueryClient } from './QueryClientProvider' + +import type { FetchQueryOptions, QueryClient } from '@tanstack/query-core' + +export function usePrefetchQueries( + options: { + queries: ReadonlyArray + }, + queryClient?: QueryClient, +) { + const client = useQueryClient(queryClient) + + for (const query of options.queries) { + if (!client.getQueryState(query.queryKey)) { + client.prefetchQuery(query) + } + } +}