Redux Toolkit 16 ⚛️ RTK Query Lifecycle — Handling polling, lazy queries, and prefetching
RTK Query’s lifecycle is not a single mount-to-unmount span. It is a set of subscription behaviors that determine when a query fetches, when it re-fetches, and when it stops. Three mechanisms control that lifecycle beyond the initial fetch: polling re-fetches on an interval while a component remains subscribed, lazy queries defer the fetch until a trigger function is called, and prefetching starts a request before any component subscribes. Each mechanism addresses a different timing problem, and each has a specific place in the lifecycle.
This chapter covers the three mechanisms and the lifecycle events they hook into. It explains the pollingInterval option and the skipPollingIfUnfocused interaction, the useLazyQuery trigger and the preferCacheValue argument, and the initiate thunk and usePrefetch hook that start requests outside the render cycle. By the end, you will understand when a query fetches on mount, when it fetches again, and how to control both.
Key point: Polling is a subscription option, not a global setting. A query polls only while at least one component is subscribed with a pollingInterval. When the last subscriber unmounts, polling stops, even if other components use the same cached data without a polling option.
Why these three mechanisms exist
The freshness problem. A dashboard that shows server metrics needs to update without a page reload. The initial fetch on mount is not enough; the data goes stale within seconds. pollingInterval tells RTK Query to re-fetch at a fixed interval for as long as the component is mounted. The option is per-subscription, not per-endpoint, so one component can poll a query while another uses the same cached data without polling .
The user-action problem. A search query should not run when the page loads. It should run when the user types and submits, or clicks a button. A regular query hook fetches on mount, which is the wrong behavior for a search. useLazyQuery returns a trigger function instead of fetching immediately. The fetch happens when the trigger is called, and the trigger receives the argument for the request .
The perceived-performance problem. A user hovers over a link to a detail page. The navigation will happen in a moment, and the detail page will fetch its data on mount. If the data were already in the cache, the page would render instantly. Prefetching starts the request before the navigation, so the cache is populated when the component mounts. The initiate thunk and the usePrefetch hook both start a request without a component subscription .
a. Polling with pollingInterval
Polling is configured through the hook’s options object. The pollingInterval value is in milliseconds, and the default is 0, which disables polling .
const { data, isFetching } = useGetPostsQuery(undefined, {
pollingInterval: 3000,
})
The interval starts when the component mounts and the query has a subscription. It stops when the component unmounts or when the last subscriber with a polling interval disappears. A component that uses the same query without pollingInterval shares the cached data but does not cause polling. The polling behavior belongs to the subscriber that requested it .
The skipPollingIfUnfocused option, when set to true, pauses the polling interval while the browser tab is in the background. Polling resumes when the tab regains focus. This is useful for dashboards that should not consume resources when nobody is looking at them .
The isFetching flag is true during a polling re-fetch, while isLoading remains false because data already exists in the cache. This distinction is what allows a component to show a subtle “refreshing” indicator without replacing the content with a loading skeleton .
b. Lazy queries with useLazyQuery
The useLazyQuery hook returns a tuple: the trigger function, the query result, and the last promise info. The query does not run on mount. It runs when the trigger is called.
const [trigger, result] = useLazyGetPostsQuery()
const handleSearch = () => {
trigger(searchTerm)
}
The trigger function accepts the query argument and returns a promise. The result object has the same fields as a regular query result (data, isLoading, isFetching, error), but the initial state is isUninitialized: true because no request has been made .
The trigger has a second argument: preferCacheValue. When set to true, the trigger returns the cached value if one exists for the same query argument, instead of making a new request. This is useful when the same lazy query is triggered from multiple components and the cache should be reused .
A common pattern is to combine useLazyQuery with skip on a regular query when the component needs both the trigger and the reactive state from other subscribers. The trigger starts the fetch, and the regular hook with skip: true reads the result without causing a duplicate request .
c. Prefetching with initiate and usePrefetch
Prefetching starts a request before a component subscribes. The request populates the cache, and when the component mounts and subscribes, it receives the cached data without a new network request.
The initiate thunk is the low-level mechanism. Every endpoint has an initiate function that dispatches the query. Calling it starts the request and returns a subscription object.
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { api } from './api'
function App() {
const dispatch = useDispatch()
useEffect(() => {
dispatch(api.endpoints.getPosts.initiate())
}, [dispatch])
}
The usePrefetch hook is the higher-level mechanism. It returns a function that prefetches an endpoint with the given argument. It is designed for user interactions like hover or focus, where the request should start before the navigation.
const prefetchPost = usePrefetch('getPost')
<Link
to={`/posts/${id}`}
onMouseEnter={() => prefetchPost(id)}
>
{post.title}
</Link>
The usePrefetch function accepts the endpoint name and optional default options. The returned function accepts the query argument and optional per-call options. The prefetch does not create a lasting subscription; the request populates the cache, and the cache entry is kept for keepUnusedDataFor seconds (default 60) after the last subscriber unmounts .
Complete Example Session
// ============================================
// PART 1: A BASIC QUERY HOOK
// ============================================
const { data, isLoading, isFetching } = useGetPostsQuery()
// Fetches on mount.
// isLoading is true only on the initial load.
// isFetching is true during any re-fetch.
// ============================================
// PART 2: ADDING POLLING
// ============================================
const { data, isFetching } = useGetPostsQuery(undefined, {
pollingInterval: 5000,
})
// Re-fetches every 5 seconds while mounted.
// Stops when the component unmounts.
// ============================================
// PART 3: PAUSING POLLING WHEN UNFOCUSED
// ============================================
const { data } = useGetPostsQuery(undefined, {
pollingInterval: 5000,
skipPollingIfUnfocused: true,
})
// Polling pauses when the tab is in the background.
// Resumes when the tab regains focus.
// ============================================
// PART 4: THE ISFETCHING DISTINCTION
// ============================================
if (isLoading) return <Skeleton />
if (!data) return <Empty />
return (
<div>
{isFetching && <Spinner />}
<PostList posts={data} />
</div>
)
// isLoading: initial load only.
// isFetching: any re-fetch, including polling.
// ============================================
// PART 5: A LAZY QUERY
// ============================================
const [trigger, result] = useLazyGetPostsQuery()
// No request on mount.
// result.isUninitialized is true.
// ============================================
// PART 6: TRIGGERING THE LAZY QUERY
// ============================================
const handleSearch = async () => {
const { data } = await trigger(searchTerm)
// handle the result
}
// The trigger starts the request.
// It returns a promise that resolves with the result.
// ============================================
// PART 7: PREFER CACHED VALUE
// ============================================
const handleSearch = async () => {
const { data } = await trigger(searchTerm, true)
// If a cached result exists, use it.
}
// The second argument is preferCacheValue.
// true returns cached data instead of re-fetching.
// ============================================
// PART 8: PREFETCHING WITH usePrefetch
// ============================================
const prefetchPost = usePrefetch('getPost')
<Link
to={`/posts/${id}`}
onMouseEnter={() => prefetchPost(id)}
>
{post.title}
</Link>
// The request starts on hover.
// The navigation finds cached data.
// ============================================
// PART 9: PREFETCHING WITH initiate
// ============================================
useEffect(() => {
dispatch(api.endpoints.getPosts.initiate())
}, [dispatch])
// Starts the request before any component subscribes.
// The cache entry is ready when a component mounts.
// ============================================
// PART 10: THE LIFECYCLE SUMMARY
// ============================================
// Mount with no options: fetch once.
// Mount with pollingInterval: fetch once, then on interval.
// useLazyQuery: no fetch until trigger is called.
// Prefetch: fetch before any subscription exists.
The ten parts covered the basic query, polling, pausing polling when unfocused, the isFetching distinction, lazy queries, the trigger, preferCacheValue, usePrefetch, initiate, and the lifecycle summary.
Quick Reference
Polling options
| Option | Default | Purpose |
|---|---|---|
pollingInterval | 0 (off) | Re-fetch interval in ms |
skipPollingIfUnfocused | false | Pause polling when tab is backgrounded |
Lazy query return values
| Value | Type | Purpose |
|---|---|---|
trigger | Function | Starts the request |
result | Object | Query state (data, isLoading, etc.) |
trigger(arg, preferCacheValue) | Promise | Second arg uses cache if available |
Prefetch mechanisms
| Mechanism | Use case |
|---|---|
usePrefetch(endpoint) | Hover/focus prefetch |
dispatch(api.endpoints.X.initiate(arg)) | Prefetch in effect or loader |
Lifecycle states
| State | Meaning |
|---|---|
isUninitialized | Lazy query has not been triggered |
isLoading | Initial fetch, no cached data |
isFetching | Re-fetch in progress, cached data exists |
isSuccess | Data is available |
Best Practices
✅ Do This:
// Use pollingInterval for live data
pollingInterval: 5000 // ✅
// Pause polling when the tab is hidden
skipPollingIfUnfocused: true // ✅
// Use useLazyQuery for user-triggered searches
const [trigger] = useLazyGetPostsQuery() // ✅
// Prefetch on hover to make navigation instant
onMouseEnter={() => prefetchPost(id)} // ✅
❌ Don’t Do This:
// Poll every query in the app
// Polling is per-subscription; don't apply it globally // ❌
// Use a regular query for a search input
// It fetches on mount with an empty argument // ❌
// Prefetch without a reason
// Every prefetch is a network request // ❌
// Assume isFetching means the initial load
// Use isLoading for the initial load // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Polling does not stop | Multiple subscribers with polling | Remove pollingInterval from unused subscribers |
| Lazy query shows stale data | preferCacheValue not set | Pass true as the second trigger argument |
| Prefetch does not help | Cache entry expired before navigation | Check keepUnusedDataFor; prefetch closer to navigation |
| Duplicate requests | Prefetch and mount fetch race | Use preferCacheValue or rely on cache deduplication |
isLoading never true | Data already in cache | Use isFetching for re-fetch indicators |
Real-World Examples
1. Polling dashboard
useGetMetricsQuery(undefined, { pollingInterval: 10000 })
2. Paused polling
useGetMetricsQuery(undefined, { pollingInterval: 10000, skipPollingIfUnfocused: true })
3. Lazy search
const [search] = useLazySearchQuery()
onClick={() => search(term)}
4. Lazy with cache preference
search(term, true)
5. Hover prefetch
const prefetch = usePrefetch('getPost')
onMouseEnter={() => prefetch(id)}
6. Effect prefetch
useEffect(() => { dispatch(api.endpoints.getPosts.initiate()) }, [])
7. Re-fetch indicator
{isFetching && <Spinner />}
8. Initial load skeleton
if (isLoading) return <Skeleton />
9. Conditional lazy trigger
if (!result.data) trigger(arg)
10. Polling with skip
useGetPostsQuery(undefined, { pollingInterval: 5000, skip: !active })
Visual
Polling lifecycle
┌─────────────────────────────────────────────────────────────┐
│ POLLING │
│ │
│ Component mounts │
│ │ │
│ ▼ │
│ Initial fetch │
│ │ │
│ ▼ │
│ Wait pollingInterval │
│ │ │
│ ▼ │
│ Re-fetch (isFetching = true) │
│ │ │
│ ▼ │
│ Wait pollingInterval │
│ │ │
│ ▼ │
│ ... │
│ │ │
│ Component unmounts │
│ │ │
│ ▼ │
│ Polling stops │
└─────────────────────────────────────────────────────────────┘
Lazy query lifecycle
┌─────────────────────────────────────────────────────────────┐
│ LAZY QUERY │
│ │
│ Component mounts │
│ │ │
│ ▼ │
│ No fetch. isUninitialized = true │
│ │ │
│ ▼ │
│ User triggers │
│ │ │
│ ▼ │
│ Fetch starts │
│ │ │
│ ▼ │
│ Result populates │
└─────────────────────────────────────────────────────────────┘
Prefetch flow
┌─────────────────────────────────────────────────────────────┐
│ PREFETCH │
│ │
│ User hovers link │
│ │ │
│ ▼ │
│ prefetchPost(id) │
│ │ │
│ ▼ │
│ Request starts (no subscription) │
│ │ │
│ ▼ │
│ Cache populated │
│ │ │
│ ▼ │
│ User navigates │
│ │ │
│ ▼ │
│ Component mounts, reads cache │
│ │ │
│ ▼ │
│ Instant render │
└─────────────────────────────────────────────────────────────┘
isFetching vs isLoading
┌─────────────────────────────────────────────────────────────┐
│ isLoading isFetching │
│ │
│ Initial load Any re-fetch │
│ No cached data Cached data exists │
│ Show skeleton Show spinner over content │
│ │
│ Polling: isLoading = false, isFetching = true │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Polling option | pollingInterval (ms, default 0) |
| Pause on background | skipPollingIfUnfocused |
| Lazy hook | useLazyQuery |
| Lazy trigger | trigger(arg, preferCacheValue) |
| Prefetch hook | usePrefetch(endpoint) |
| Prefetch thunk | dispatch(api.endpoints.X.initiate(arg)) |
| Initial load flag | isLoading |
| Re-fetch flag | isFetching |
| Cache retention | keepUnusedDataFor (default 60s) |
Key takeaways:
- Polling is a subscription option, not an endpoint setting. A query polls only while a component is subscribed with a
pollingInterval. When the last polling subscriber unmounts, polling stops, and other subscribers that use the cached data are unaffected . isFetchingandisLoadingmean different things.isLoadingistrueonly on the initial load when no cached data exists.isFetchingistrueduring any re-fetch, including polling and manual refetch. UsingisLoadingfor a polling indicator would show a skeleton on every poll .useLazyQuerydefers the fetch until the trigger is called. The trigger accepts the query argument and returns a promise. ThepreferCacheValuesecond argument returns cached data instead of re-fetching, which is useful when the same argument has already been fetched .- Prefetching starts the request before a subscription exists.
usePrefetchis for hover and focus interactions.initiateis for effects, route loaders, or setup code. Both populate the cache so the eventual component mount reads from cache instead of fetching . - Prefetch does not create a lasting subscription. The cache entry is kept for
keepUnusedDataForseconds after the last subscriber unmounts. If the navigation happens after that window, the prefetch is wasted and the component fetches again. - The three mechanisms compose. A lazy query can poll if triggered with a polling option. A prefetched query can be read by a component that also polls. The lifecycle is the combination of subscription behaviors, not a single fixed sequence.
Remember: RTK Query’s lifecycle is controlled by subscriptions. Mounting a query hook creates a subscription and triggers a fetch. Unmounting removes the subscription and starts the cache retention timer. Polling is a subscription option that re-fetches on an interval. Lazy queries delay the subscription until the trigger is called. Prefetching starts the fetch before the subscription exists. The three mechanisms answer three different timing questions: “keep it fresh,” “fetch on demand,” and “fetch before it is needed.” Use polling for live data, lazy queries for user actions, and prefetching for navigation that should feel instant.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!