Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions docs/rtk-query/usage/prefetching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/toolkit/src/query/core/buildThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,14 +994,17 @@ In the case of an unhandled error, no tags will be "provided" or "invalidated".`
<EndpointName extends QueryKeys<Definitions>>(
endpointName: EndpointName,
arg: any,
options: PrefetchOptions,
options: PrefetchOptions = {},
): ThunkAction<void, any, any, UnknownAction> =>
(dispatch: ThunkDispatch<any, any, any>, 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<any, any>
).initiate(arg, options)
Expand Down
2 changes: 1 addition & 1 deletion packages/toolkit/src/query/core/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export interface ApiModules<
prefetch<EndpointName extends QueryKeys<Definitions>>(
endpointName: EndpointName,
arg: QueryArgFrom<Definitions[EndpointName]>,
options: PrefetchOptions,
options?: PrefetchOptions,
): ThunkAction<void, any, any, UnknownAction>
/**
* 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.
Expand Down
59 changes: 56 additions & 3 deletions packages/toolkit/src/query/tests/buildHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1604,7 +1603,7 @@ describe('hooks tests', () => {
}),
})

const timeoutStoreRef = setupApiStore(timeoutApi as any, undefined, {
const timeoutStoreRef = setupApiStore(timeoutApi, undefined, {
withoutTestLifecycles: true,
})

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, void>({
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 <div>{result.data}</div>
}

const { unmount } = render(<TestComponent />, {
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', () => {
Expand Down
191 changes: 158 additions & 33 deletions packages/toolkit/src/query/tests/buildThunks.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<any, any>({
query: (obj) => obj,
getUser: build.query<any, number>({
query: (id) => ({ url: `user/${id}` }),
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
updateUser: build.mutation<any, { id: number; name: string }>({
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<string, any>
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)
})
})
})
Loading