diff --git a/docs/rtk-query/usage/prefetching.mdx b/docs/rtk-query/usage/prefetching.mdx index 405109fb9c..fdc6b211f3 100644 --- a/docs/rtk-query/usage/prefetching.mdx +++ b/docs/rtk-query/usage/prefetching.mdx @@ -19,6 +19,25 @@ There are a handful of situations that you may want to do this, but some very co 3. User hovers over a next pagination button 4. User navigates to a page and you know that some components down the tree will require said data. This way, you can prevent fetching waterfalls. +## Prefetching vs Subscriptions + +Prefetching is designed as a "fire and forget" operation that loads data into the cache without creating an ongoing subscription. This means: + +- **No automatic refetching**: Prefetched data will not automatically refetch when tags are invalidated +- **No subscription management**: You don't need to (and can't) manually unsubscribe from prefetched data +- **Cache cleanup**: Prefetched data without active subscriptions may be removed during normal cache cleanup +- **Returns void**: The prefetch trigger function doesn't return a promise or subscription handle + +If you need data that automatically refetches on invalidation or stays in the cache as long as a component is mounted, use a query hook like `useQuery` or `useQuerySubscription` instead. Prefetch is ideal for warming the cache before a user action, while query hooks are for ongoing data needs. + +:::tip When to use prefetch vs query hooks + +- **Use prefetch** when you want to load data ahead of time (e.g., on hover) but don't need it to stay fresh +- **Use query hooks** when you need data that automatically refetches on invalidation and stays in cache while the component is mounted +- **Use both together**: Prefetch on hover, then let the query hook create a subscription when the user navigates + +::: + ## Prefetching with React Hooks Similar to the [`useMutation`](./mutations) hook, the `usePrefetch` hook will not run automatically — it returns a "trigger function" that can be used to initiate the behavior. @@ -111,12 +130,30 @@ store.dispatch( ) ``` -You can also dispatch the query action, but you would be responsible for implementing any additional logic. +### Prefetch vs `initiate()` -```ts title="Alternate method of manual prefetching" no-transpile -dispatch(api.endpoints[endpointName].initiate(arg, { forceRefetch: true })) +While you can also use `initiate()` directly, there are important differences: + +```ts title="Using initiate() directly" no-transpile +// This creates a subscription that must be manually cleaned up +const promise = dispatch( + api.endpoints[endpointName].initiate(arg, { + subscribe: true, // Creates a subscription (default) + forceRefetch: true, + }), +) + +// You must manually unsubscribe to prevent memory leaks +promise.unsubscribe() ``` +**Key differences:** + +- **`api.util.prefetch()`**: Automatically uses `subscribe: false`, no cleanup needed +- **`endpoint.initiate()`**: Defaults to `subscribe: true`, requires manual `unsubscribe()` call + +Use `prefetch()` for simple "load and forget" scenarios. Use `initiate()` directly only when you need fine-grained control over subscriptions and are prepared to manage the subscription lifecycle yourself. + ## Prefetching Examples ### Basic Prefetching diff --git a/packages/toolkit/src/query/core/buildThunks.ts b/packages/toolkit/src/query/core/buildThunks.ts index f8e3b33a54..a1481cac15 100644 --- a/packages/toolkit/src/query/core/buildThunks.ts +++ b/packages/toolkit/src/query/core/buildThunks.ts @@ -994,14 +994,17 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".` >( endpointName: EndpointName, arg: any, - options: PrefetchOptions, + options: PrefetchOptions = {}, ): ThunkAction => (dispatch: ThunkDispatch, getState: () => any) => { const force = hasTheForce(options) && options.force const maxAge = hasMaxAge(options) && options.ifOlderThan const queryAction = (force: boolean = true) => { - const options = { forceRefetch: force, isPrefetch: true } + const options: StartQueryActionCreatorOptions = { + forceRefetch: force, + subscribe: false, + } return ( api.endpoints[endpointName] as ApiEndpointQuery ).initiate(arg, options) diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts index e2857fffcc..a07306ec7b 100644 --- a/packages/toolkit/src/query/core/module.ts +++ b/packages/toolkit/src/query/core/module.ts @@ -248,7 +248,7 @@ export interface ApiModules< prefetch>( endpointName: EndpointName, arg: QueryArgFrom, - options: PrefetchOptions, + options?: PrefetchOptions, ): ThunkAction /** * A Redux thunk action creator that, when dispatched, creates and applies a set of JSON diff/patch objects to the current state. This immediately updates the Redux state with those changes. diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index ae943954de..31429ed66b 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -1592,7 +1592,6 @@ describe('hooks tests', () => { // Create a fresh API instance with fetchBaseQuery and timeout, matching the user's example const timeoutApi = createApi({ - reducerPath: 'timeoutApi', baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com', timeout: 5000, @@ -1604,7 +1603,7 @@ describe('hooks tests', () => { }), }) - const timeoutStoreRef = setupApiStore(timeoutApi as any, undefined, { + const timeoutStoreRef = setupApiStore(timeoutApi, undefined, { withoutTestLifecycles: true, }) @@ -2861,7 +2860,7 @@ describe('hooks tests', () => { }) }) - test('usePrefetch returns the last success result when ifOlderThan evalutes to false', async () => { + test('usePrefetch returns the last success result when ifOlderThan evaluates to false', async () => { const user = userEvent.setup() const { usePrefetch } = api @@ -2944,6 +2943,60 @@ describe('hooks tests', () => { status: 'pending', }) }) + + it('should create subscription when hook mounts after prefetch', async () => { + const api = createApi({ + baseQuery: async () => ({ data: 'test data' }), + endpoints: (build) => ({ + getTest: build.query({ + query: () => '', + }), + }), + }) + const storeRef = setupApiStore(api, undefined, { withoutListeners: true }) + + // 1. Prefetch data (no subscription) + await storeRef.store.dispatch(api.util.prefetch('getTest', undefined)) + + // Verify data is cached + await waitFor(() => { + let state = storeRef.store.getState() + expect(state.api.queries['getTest(undefined)']?.data).toBe('test data') + }) + + // Verify no subscription exists + const subscriptions = storeRef.store.dispatch( + api.internalActions.internal_getRTKQSubscriptions(), + ) as any + expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0) + + // 2. Mount component with useQuery hook + function TestComponent() { + const result = api.endpoints.getTest.useQuery() + return
{result.data}
+ } + + const { unmount } = render(, { + wrapper: storeRef.wrapper, + }) + + // Wait for hook to initialize + await waitFor(() => { + // EXPECTED: Subscription should be created + expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(1) + }) + + // 3. Verify data is still available + let state = storeRef.store.getState() + expect(state.api.queries['getTest(undefined)']?.data).toBe('test data') + + // 4. Unmount and verify subscription is removed + unmount() + + await waitFor(() => { + expect(subscriptions.getSubscriptionCount('getTest(undefined)')).toBe(0) + }) + }) }) describe('useQuery and useMutation invalidation behavior', () => { diff --git a/packages/toolkit/src/query/tests/buildThunks.test.tsx b/packages/toolkit/src/query/tests/buildThunks.test.tsx index f197afcc90..66eca2795b 100644 --- a/packages/toolkit/src/query/tests/buildThunks.test.tsx +++ b/packages/toolkit/src/query/tests/buildThunks.test.tsx @@ -1,7 +1,11 @@ -import { configureStore, isAllOf } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query/react' import { renderHook, waitFor } from '@testing-library/react' -import { actionsReducer, withProvider } from '../../tests/utils/helpers' +import { + actionsReducer, + setupApiStore, + withProvider, +} from '../../tests/utils/helpers' import type { BaseQueryApi } from '../baseQueryTypes' describe('baseline thunk behavior', () => { @@ -246,50 +250,171 @@ describe('re-triggering behavior on arg change', () => { }) describe('prefetch', () => { - const baseQuery = () => ({ data: null }) + const baseQuery = () => ({ data: { name: 'Test User' } }) + const api = createApi({ baseQuery, + tagTypes: ['User'], endpoints: (build) => ({ - getUser: build.query({ - query: (obj) => obj, + getUser: build.query({ + query: (id) => ({ url: `user/${id}` }), + providesTags: (result, error, id) => [{ type: 'User', id }], + }), + updateUser: build.mutation({ + query: ({ id, name }) => ({ + url: `user/${id}`, + method: 'PUT', + body: { name }, + }), + invalidatesTags: (result, error, { id }) => [{ type: 'User', id }], }), }), + keepUnusedDataFor: 0.1, // 100ms for faster test cleanup }) - const store = configureStore({ - reducer: { [api.reducerPath]: api.reducer, ...actionsReducer }, - middleware: (gDM) => gDM().concat(api.middleware), + let storeRef = setupApiStore( + api, + { ...actionsReducer }, + { + withoutListeners: true, + }, + ) + + let getSubscriptions: () => Map + let getSubscriptionCount: (queryCacheKey: string) => number + + beforeEach(() => { + storeRef = setupApiStore( + api, + { ...actionsReducer }, + { + withoutListeners: true, + }, + ) + // Get subscription helpers + const subscriptionSelectors = storeRef.store.dispatch( + api.internalActions.internal_getRTKQSubscriptions(), + ) as any + getSubscriptions = subscriptionSelectors.getSubscriptions + getSubscriptionCount = subscriptionSelectors.getSubscriptionCount }) - it('should attach isPrefetch if prefetching', async () => { - store.dispatch(api.util.prefetch('getUser', 1, {})) - await Promise.all(store.dispatch(api.util.getRunningQueriesThunk())) + describe('subscription behavior', () => { + it('prefetch should NOT create a subscription', async () => { + const queryCacheKey = 'getUser(1)' - const isPrefetch = ( - action: any, - ): action is { meta: { arg: { isPrefetch: true } } } => - action?.meta?.arg?.isPrefetch + // Initially no subscriptions + expect(getSubscriptionCount(queryCacheKey)).toBe(0) - expect(store.getState().actions).toMatchSequence( - api.internalActions.middlewareRegistered.match, - isAllOf(api.endpoints.getUser.matchPending, isPrefetch), - isAllOf(api.endpoints.getUser.matchFulfilled, isPrefetch), - ) + // Dispatch prefetch + storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) - // compare against a regular initiate call - await store.dispatch( - api.endpoints.getUser.initiate(1, { forceRefetch: true }), - ) + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + }) - const isNotPrefetch = (action: any): action is unknown => - !isPrefetch(action) + it('prefetch allows cache cleanup after keepUnusedDataFor', async () => { + const queryCacheKey = 'getUser(1)' - expect(store.getState().actions).toMatchSequence( - api.internalActions.middlewareRegistered.match, - isAllOf(api.endpoints.getUser.matchPending, isPrefetch), - isAllOf(api.endpoints.getUser.matchFulfilled, isPrefetch), - isAllOf(api.endpoints.getUser.matchPending, isNotPrefetch), - isAllOf(api.endpoints.getUser.matchFulfilled, isNotPrefetch), - ) + // Prefetch the data + storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + + // Verify data is in cache + let state = api.endpoints.getUser.select(1)(storeRef.store.getState()) + expect(state.data).toEqual({ name: 'Test User' }) + + // Wait longer than keepUnusedDataFor + await new Promise((resolve) => setTimeout(resolve, 150)) + + state = api.endpoints.getUser.select(1)(storeRef.store.getState()) + expect(state.status).toBe('uninitialized') + expect(state.data).toBeUndefined() + }) + + it('prefetch does NOT trigger refetch on tag invalidation', async () => { + // Prefetch user 1 + storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + + // Verify data is in cache + let state = api.endpoints.getUser.select(1)(storeRef.store.getState()) + expect(state.data).toEqual({ name: 'Test User' }) + + // Invalidate the tag by updating the user + await storeRef.store.dispatch( + api.endpoints.updateUser.initiate({ id: 1, name: 'Updated' }), + ) + + // Since there's no subscription, the cache entry gets removed on invalidation + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + + // Cache entry should be cleared (no subscription to keep it alive) + state = api.endpoints.getUser.select(1)(storeRef.store.getState()) + expect(state.status).toBe('uninitialized') + expect(state.data).toBeUndefined() + }) + + it('multiple prefetches do not accumulate subscriptions', async () => { + const queryCacheKey = 'getUser(1)' + + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + + // First prefetch + storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + + // Second prefetch (force refetch) + storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true })) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + + // Still no subscriptions + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + + // Third prefetch + storeRef.store.dispatch(api.util.prefetch('getUser', 1, { force: true })) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + }) + + it('prefetch followed by regular query should work correctly', async () => { + const queryCacheKey = 'getUser(1)' + + // Prefetch first + storeRef.store.dispatch(api.util.prefetch('getUser', 1, {})) + await Promise.all( + storeRef.store.dispatch(api.util.getRunningQueriesThunk()), + ) + + // No subscription from prefetch + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + + // Now create a real subscription via initiate + const promise = storeRef.store.dispatch(api.endpoints.getUser.initiate(1)) + + // Should have 1 subscription from the initiate call + expect(getSubscriptionCount(queryCacheKey)).toBe(1) + + // Unsubscribe + promise.unsubscribe() + + // Subscription should be cleaned up + expect(getSubscriptionCount(queryCacheKey)).toBe(0) + }) }) })