Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is a common UI pattern.
RTK Query supports this use case via "infinite query" endpoints. Infinite Query endpoints are similar to standard query endpoints, in that they fetch data and cache the results. However, infinite query endpoints have the ability to fetch "next" and "previous" pages, and contain all related fetched pages in a single cache entry.
Infinite Query ConceptsRTK Query's support for infinite queries is modeled after React Query's infinite query API design.
Query Args, Page Params, and Cache StructureWith standard query endpoints:
query
or queryFn
function that will calculate the desired URL or do the actual fetchingdata
field in the cache entryInfinite queries work similarly, but have a couple additional layers:
query
and queryFn
methods will receive a combined object with {queryArg, pageParam}
as the first argument, instead of just the queryArg
by itself.data
field in the cache entry stores a {pages: DataType[], pageParams: PageParam[]}
structure that contains all of the fetched page results and their corresponding page params used to fetch them.For example, a Pokemon API endpoint might have a string query arg like "fire"
, but use a page number as the param to determine which page to fetch out of the results. For a query like useGetPokemonInfiniteQuery('fire')
, the resulting cache data might look like this:
{
queries: {
"getPokemon('fire')": {
data: {
pages: [
["Charmander", "Charmeleon"],
["Charizard", "Vulpix"],
["Magmar", "Flareon"]
],
pageParams: [
1,
2,
3
]
}
}
}
}
This structure allows flexibility in how your UI chooses to render the data (showing individual pages, flattening into a single list), enables limiting how many pages are kept in cache, and makes it possible to dynamically determine the next or previous page to fetch based on either the data or the page params.
Defining Infinite Query EndpointsInfinite query endpoints are defined by returning an object inside the endpoints
section of createApi
, and defining the fields using the build.infiniteQuery()
method. They are an extension of standard query endpoints - you can specify the same options as standard queries (providing either query
or queryFn
, customizing with transformResponse
, lifecycles with onCacheEntryAdded
and onQueryStarted
, defining tags, etc). However, they also require an additional infiniteQueryOptions
field to specify the infinite query behavior.
With TypeScript, you must supply 3 generic arguments: build.infiniteQuery<ResultType, QueryArg, PageParam>
, where ResultType
is the contents of a single page, QueryArg
is the type passed in as the cache key, and PageParam
is the value used to request a specific page. If there is no argument, use void
for the arg type instead.
infiniteQueryOptions
The infiniteQueryOptions
field includes:
initialPageParam
: the default page param value used for the first request, if this was not specified at the usage sitemaxPages
: an optional limit to how many fetched pages will be kept in the cache entry at a timegetNextPageParam
: a required callback you must provide to calculate the next page param, given the existing cached pages and page paramsgetPreviousPageParam
: an optional callback that will be used to calculate the previous page param, if you try to fetch backwards.Both initialPageParam
and getNextPageParam
are required, to ensure the infinite query can properly fetch the next page of data. Also, initialPageParam
may be specified when using the endpoint, to override the default value for a first fetch. maxPages
and getPreviousPageParam
are both optional.
getNextPageParam
and getPreviousPageParam
are user-defined, giving you flexibility to determine how those values are calculated:
export type PageParamFunction<DataType, PageParam, QueryArg> = (
currentPage: DataType,
allPages: DataType[],
currentPageParam: PageParam,
allPageParams: PageParam[],
queryArg: QueryArg,
) => PageParam | undefined | null
A page param can be any value at all: numbers, strings, objects, arrays, etc. Since the existing page param values are stored in Redux state, you should still treat those immutably. For example, if you had a param structure like {page: Number, filters: Filters}
, incrementing the page would look like return {...currentPageParam, page: currentPageParam.page + 1}
.
Since both actual page contents and page params are passed in, you can calculate new page params based on any of those. This enables a number of possible infinite query use cases, including cursor-based and limit+offset-based queries.
The "current" arguments will be either the last page for getNextPageParam
, or the first page for getPreviousPageParam
.
The list of arguments is the same as with React Query, but with the addition of queryArg
at the end. (This is because React Query always has access to the query arg when you pass the options to its useQuery
hook, but with RTK Query the endpoints are defined separately, so this makes the query arg accessible if you need it to calculate the page params.)
If there is no possible page to fetch in that direction, the callback should return undefined
.
A complete example of this for a fictional Pokemon API service might look like:
Infinite Query definition example
type Pokemon = {
id: string
name: string
}
const pokemonApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/pokemon' }),
endpoints: (build) => ({
getInfinitePokemonWithMax: build.infiniteQuery<Pokemon[], string, number>({
infiniteQueryOptions: {
initialPageParam: 1,
maxPages: 3,
getNextPageParam: (
lastPage,
allPages,
lastPageParam,
allPageParams,
queryArg,
) => lastPageParam + 1,
getPreviousPageParam: (
firstPage,
allPages,
firstPageParam,
allPageParams,
queryArg,
) => {
return firstPageParam > 0 ? firstPageParam - 1 : undefined
},
},
query({ queryArg, pageParam }) {
return `/type/${queryArg}?page=${pageParam}`
},
}),
}),
})
Performing Infinite Queries with React Hooks
Similar to query endpoints, RTK Query will automatically generate React hooks for infinite query endpoints based on the name of the endpoint. An endpoint field with getPokemon: build.infiniteQuery()
will generate a hook named useGetPokemonInfiniteQuery
, as well as a generically-named hook attached to the endpoint, like api.endpoints.getPokemon.useInfiniteQuery
.
There are 3 infinite query-related hooks:
useInfiniteQuery
useInfiniteQuerySubscription
and useInfiniteQueryState
, and is the primary hook. Automatically triggers fetches of data from an endpoint, 'subscribes' the component to the cached data, and reads the request status and cached data from the Redux store.useInfiniteQuerySubscription
refetch
function and fetchNext/PreviousPage
functions, and accepts all hooks options. Automatically triggers refetches of data from an endpoint, and 'subscribes' the component to the cached data.useInfiniteQueryState
skip
and selectFromResult
. Reads the request status and cached data from the Redux store.In practice, the standard useInfiniteQuery
-based hooks such as useGetPokemonInfiniteQuery
will be the primary hooks used in your application, but the other hooks are available for specific use cases.
The query hooks expect two parameters: (queryArg?, queryOptions?)
.
The queryOptions
object accepts all the same parameters as useQuery
, including skip
, selectFromResult
, and refetching/polling options.
Unlike normal query hooks, your query
or queryFn
callbacks will receive a "page param" value to generate the URL or make the request, instead of the "query arg" that was passed to the hook. By default, the initialPageParam
value specified in the endpoint will be used to make the first request, and then your getNext/PreviousPageParam
callbacks will be used to calculate further page params as you fetch forwards or backwards.
If you want to start from a different page param, you may override the initialPageParam
by passing it as part of the hook options:
const { data } = useGetPokemonInfiniteQuery('fire', {
initialPageParam: 3,
})
The next and previous page params will still be calculated as needed.
Frequently Used Query Hook Return ValuesInfinite query hooks return the same result object as normal query hooks, but with a few additional fields specific to infinite queries and a different structure for data
and currentData
.
data
/ currentData
: These contain the same "latest successful" and "latest for current arg" results as normal queries, but the value is the {pages, pageParams}
infinite query object with all fetched pages instead of a single response value.hasNextPage
/ hasPreviousPage
: When true, indicates that there should be another page available to fetch in that direction. This is calculated by calling getNext/PreviousPageParam
with the latest fetched pages.isFetchingNext/PreviousPage
: When true, indicates that the current isFetching
flag represents a fetch in that direction.isFetchNext/PreviousPageError
: When true, indicates that the current isError
flag represents an error for a failed fetch in that directionfetchNext/PreviousPage
: methods that will trigger a fetch for another page in that direction.In most cases, you will probably read data
and either isLoading
or isFetching
in order to render your UI. You will also want to use the fetchNext/PreviousPage
methods to trigger fetching additional pages.
For infinite query hooks, the data
field returned by the hook will be the {pages, pageParams}
structure containing all fetched pages, instead of just a single response value.
This gives you control over how the data is used for display. You can flatten all the page contents into a single array for infinite scrolling, use the individual page results for pagination, sort or reverse the entries, or any other logic you need for rendering the UI with this data.
As with any other Redux data, you should avoid mutating these arrays (including calling array.sort/reverse()
directly on the existing references).
Here is an example of a typical infinite query endpoint definition, and hook usage in a component:
type Pokemon = {
id: string
name: string
}
const pokemonApi = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/pokemon' }),
endpoints: (build) => ({
getPokemon: build.infiniteQuery<Pokemon[], string, number>({
infiniteQueryOptions: {
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPageParam + 1,
},
query({ queryArg, pageParam }) {
return `/type/${queryArg}?page=${pageParam}`
},
}),
}),
})
function PokemonList({ pokemonType }: { pokemonType: string }) {
const { data, isFetching, fetchNextPage, fetchPreviousPage, refetch } =
pokemonApi.useGetPokemonInfiniteQuery(pokemonType)
const handleNextPage = async () => {
await fetchNextPage()
}
const handleRefetch = async () => {
await refetch()
}
const allResults = data?.pages.flat() ?? []
return (
<div>
<div>Type: {pokemonType}</div>
<div>
{allResults.map((pokemon, i: number | null | undefined) => (
<div key={i}>{pokemon.name}</div>
))}
</div>
<button onClick={() => handleNextPage()}>Fetch More</button>
<button onClick={() => handleRefetch()}>Refetch</button>
</div>
)
}
In this example, the server returns an array of Pokemon as the response for each individual page. This component shows the results as a single list. Since the data
field itself has a pages
array of all responses, the component needs to flatten the pages into a single array to render that list. Alternately, it could map over the pages and show them in a paginated format.
Similarly, this example relies on manual user clicks on a "Fetch More" button to trigger fetching the next page, but could automatically call fetchNextPage
based on things like an IntersectionObserver
, a list component triggering some kind of "end of the list" event, or other similar indicators.
The endpoint itself only defines getNextPageParam
, so this example doesn't support fetching backwards, but that can be provided in cases where backwards fetching makes sense. The page param here is a simple incremented number, but the page param
Since all pages are stored in a single cache entry, there can only be one request in progress at a time. RTK Query already has logic built in to bail out of running a new request if there is already a request in flight for that cache entry.
That means that if you call fetchNextPage()
again while an existing request is in progress, the second call won't actually execute a request. Be sure to either await the previous fetchNextPage()
promise result first or check the isFetching
flag if you have concerns about a potential request already in progress.
The promise returned from fetchNextPage()
does have a promise.abort()
method attached that will force the earlier request to reject and not save the results. Note that this will mark the cache entry as errored, but the data will still exist. Since promise.abort()
is synchronous, you would also need to await the previous promise to ensure the rejection is handled, and then trigger the new page fetch.
When an infinite query endpoint is refetched (due to tag invalidation, polling, arg change configuration, or manual refetching), RTK Query will try to sequentially refetch all pages currently in the cache. This ensures that the client is always working with the latest data, and avoids stale cursors or duplicate records.
If the cache entry is ever removed and then re-added, it will start with only fetching the initial page.
Limiting Cache Entry SizeAll fetched pages for a given query arg are stored in the pages
array in that cache entry. By default, there is no limit to the number of stored pages - if you call fetchNextPage()
1000 times, data.pages
will have 1000 pages stored.
If you need to limit the number of stored pages (for reasons like memory usage), you can supply a maxPages
option as part of the endpoint. If provided, fetching a page when already at the max will automatically drop the last page in the opposite direction. For example, with maxPages: 3
and a cached page params of [1, 2, 3]
, calling fetchNextPage()
would result in page 1
being dropped and the new cached pages being [2, 3, 4]
. From there, calling fetchNextPage()
would result in [3, 4, 5]
, or calling fetchPreviousPage()
would go back to [1, 2, 3]
.
The getNext/PreviousPageParam
callbacks offer flexibility in how you interact with the backend API.
Here are some examples of common infinite query patterns to show how you might approach different use cases.
For additional examples, and to see some of these patterns in action, see the RTK Query "infinite queries" example app in the repo.
For a simple API that just needs page numbers, you can calculate the previous and next page numbers based on the existing page params:
const pokemonApi = createApi({
baseQuery,
endpoints: (build) => ({
getInfinitePokemon: build.infiniteQuery<Pokemon[], string, number>({
infiniteQueryOptions: {
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) =>
lastPageParam + 1,
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
return firstPageParam > 0 ? firstPageParam - 1 : undefined
},
},
query({ pageParam }) {
return `https://example.com/listItems?page=${pageParam}`
},
}),
}),
})
For an API that accepts values like page number and page size and includes total pages in the response, you can calculate whether there are more pages remaining:
type ProjectsResponse = {
projects: Project[]
serverTime: string
totalPages: number
}
type ProjectsInitialPageParam = {
page: number
size: number
}
const projectsApi = createApi({
baseQuery,
endpoints: (build) => ({
projectsPaginated: build.infiniteQuery<
ProjectsResponse,
void,
ProjectsInitialPageParam
>({
infiniteQueryOptions: {
initialPageParam: {
page: 0,
size: 20,
},
getNextPageParam: (
lastPage,
allPages,
lastPageParam,
allPageParams,
) => {
const nextPage = lastPageParam.page + 1
const remainingPages = lastPage?.totalPages - nextPage
if (remainingPages <= 0) {
return undefined
}
return {
...lastPageParam,
page: nextPage,
}
},
getPreviousPageParam: (
firstPage,
allPages,
firstPageParam,
allPageParams,
) => {
const prevPage = firstPageParam.page - 1
if (prevPage < 0) return undefined
return {
...firstPageParam,
page: prevPage,
}
},
},
query: ({ pageParam: { page, size } }) => {
return `https://example.com/api/projectsPaginated?page=${page}&size=${size}`
},
}),
}),
})
Bidirectional Cursors
If the server sends back cursor values in the response, you can use those as the page params for the next and previous requests:
type ProjectsCursorPaginated = {
projects: Project[]
serverTime: string
pageInfo: {
startCursor: number
endCursor: number
hasNextPage: boolean
hasPreviousPage: boolean
}
}
type ProjectsInitialPageParam = {
before?: number
around?: number
after?: number
limit: number
}
type QueryParamLimit = number
const projectsApi = createApi({
baseQuery,
endpoints: (build) => ({
getProjectsBidirectionalCursor: build.infiniteQuery<
ProjectsCursorPaginated,
QueryParamLimit,
ProjectsInitialPageParam
>({
infiniteQueryOptions: {
initialPageParam: { limit: 10 },
getPreviousPageParam: (
firstPage,
allPages,
firstPageParam,
allPageParams,
) => {
if (!firstPage.pageInfo.hasPreviousPage) {
return undefined
}
return {
before: firstPage.pageInfo.startCursor,
limit: firstPageParam.limit,
}
},
getNextPageParam: (
lastPage,
allPages,
lastPageParam,
allPageParams,
) => {
if (!lastPage.pageInfo.hasNextPage) {
return undefined
}
return {
after: lastPage.pageInfo.endCursor,
limit: lastPageParam.limit,
}
},
},
query: ({ pageParam: { before, after, around, limit } }) => {
const params = new URLSearchParams()
params.append('limit', String(limit))
if (after != null) {
params.append('after', String(after))
} else if (before != null) {
params.append('before', String(before))
} else if (around != null) {
params.append('around', String(around))
}
return `https://example.com/api/projectsBidirectionalCursor?${params.toString()}`,
},
}),
}),
})
Limit and Offset
If the API expects a combination of limit and offset values, those can also be calculated based on the responses and page params.
export type ProjectsResponse = {
projects: Project[]
numFound: number
serverTime: string
}
type ProjectsInitialPageParam = {
offset: number
limit: number
}
const projectsApi = createApi({
baseQuery,
endpoints: (build) => ({
projectsLimitOffset: build.infiniteQuery<
ProjectsResponse,
void,
ProjectsInitialPageParam
>({
infiniteQueryOptions: {
initialPageParam: {
offset: 0,
limit: 20,
},
getNextPageParam: (
lastPage,
allPages,
lastPageParam,
allPageParams,
) => {
const nextOffset = lastPageParam.offset + lastPageParam.limit
const remainingItems = lastPage?.numFound - nextOffset
if (remainingItems <= 0) {
return undefined
}
return {
...lastPageParam,
offset: nextOffset,
}
},
getPreviousPageParam: (
firstPage,
allPages,
firstPageParam,
allPageParams,
) => {
const prevOffset = firstPageParam.offset - firstPageParam.limit
if (prevOffset < 0) return undefined
return {
...firstPageParam,
offset: firstPageParam.offset - firstPageParam.limit,
}
},
},
query: ({ pageParam: { offset, limit } }) => {
return `https://example.com/api/projectsLimitOffset?offset=${offset}&limit=${limit}`
},
}),
}),
})
Runtime Validation using Schemas
Endpoints can use any Standard Schema compliant library for runtime validation of query args, responses, and errors. See API reference for full list of available schemas.
When used with TypeScript, schemas can also be used to infer the type of that value instead of having to declare it.
Most commonly, you'll want to use responseSchema
to validate the response from the server (or rawResponseSchema
when using transformResponse
).
Using responseSchema
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import * as v from 'valibot'
const pokemonSchema = v.object({
id: v.number(),
name: v.string(),
})
type Pokemon = v.InferOutput<typeof pokemonSchema>
const transformedPokemonSchema = v.object({
...pokemonSchema.entries,
id: v.string(),
})
type TransformedPokemon = v.InferOutput<typeof transformedPokemonSchema>
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/pokemon' }),
endpoints: (build) => ({
getInfinitePokemon: build.infiniteQuery<Pokemon[], string, number>({
query: ({ queryArg, pageParam }) => `type/${queryArg}?page=${pageParam}`,
argSchema: v.object({
queryArg: v.string(),
pageParam: v.number(),
}),
responseSchema: v.array(pokemonSchema),
}),
getTransformedPokemon: build.infiniteQuery<
TransformedPokemon[],
string,
number
>({
query: ({ queryArg, pageParam }) => `type/${queryArg}?page=${pageParam}`,
argSchema: v.object({
queryArg: v.string(),
pageParam: v.number(),
}),
rawResponseSchema: v.array(pokemonSchema),
transformResponse: (response) =>
response.map((pokemon) => ({
...pokemon,
id: String(pokemon.id),
})),
responseSchema: v.array(transformedPokemonSchema),
}),
}),
})
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4