Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists.
When using useInfiniteQuery, you'll notice a few things are different:
Note: When using options like initialData or select in your query, make sure that when you restructure your data that it still includes data.pages and data.pageParams properties, otherwise your changes will be overwritten by the query in its return!
Let's assume we have an API that returns pages of projects 3 at a time based on a cursor index along with a cursor that can be used to fetch the next group of projects:
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
With this information, we can create a "Load More" UI by:
Note: It's very important you do not call fetchNextPage with arguments unless you want them to override the pageParam data returned from the getNextPageParam function. e.g. Do not do this: <button onClick={fetchNextPage} /> as this would send the onClick event to the fetchNextPage function.
<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'
const fetchProjects = async ({ pageParam = 0 }) => {
const res = await fetch('/api/projects?cursor=' + pageParam)
return res.json()
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
</script>
<template>
<span v-if="isLoading">Loading...</span>
<span v-else-if="isError">Error: {{ error.message }}</span>
<div v-else-if="data">
<span v-if="isFetching && !isFetchingNextPage">Fetching...</span>
<ul v-for="(group, index) in data.pages" :key="index">
<li v-for="project in group.projects" :key="project.id">
{{ project.name }}
</li>
</ul>
<button
@click="() => fetchNextPage()"
:disabled="!hasNextPage || isFetchingNextPage"
>
<span v-if="isFetchingNextPage">Loading more...</span>
<span v-else-if="hasNextPage">Load More</span>
<span v-else>Nothing more to load</span>
</button>
</div>
</template>
<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'
const fetchProjects = async ({ pageParam = 0 }) => {
const res = await fetch('/api/projects?cursor=' + pageParam)
return res.json()
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
</script>
<template>
<span v-if="isLoading">Loading...</span>
<span v-else-if="isError">Error: {{ error.message }}</span>
<div v-else-if="data">
<span v-if="isFetching && !isFetchingNextPage">Fetching...</span>
<ul v-for="(group, index) in data.pages" :key="index">
<li v-for="project in group.projects" :key="project.id">
{{ project.name }}
</li>
</ul>
<button
@click="() => fetchNextPage()"
:disabled="!hasNextPage || isFetchingNextPage"
>
<span v-if="isFetchingNextPage">Loading more...</span>
<span v-else-if="hasNextPage">Load More</span>
<span v-else>Nothing more to load</span>
</button>
</div>
</template>
It's essential to understand that calling fetchNextPage while an ongoing fetch is in progress runs the risk of overwriting data refreshes happening in the background. This situation becomes particularly critical when rendering a list and triggering fetchNextPage simultaneously.
Remember, there can only be a single ongoing fetch for an InfiniteQuery. A single cache entry is shared for all pages, attempting to fetch twice simultaneously might lead to data overwrites.
If you intend to enable simultaneous fetching, you can utilize the { cancelRefetch: false } option (default: true) within fetchNextPage.
To ensure a seamless querying process without conflicts, it's highly recommended to verify that the query is not in an isFetching state, especially if the user won't directly control that call.
<List onEndReached={() => !isFetching && fetchNextPage()} />
<List onEndReached={() => !isFetching && fetchNextPage()} />
When an infinite query becomes stale and needs to be refetched, each group is fetched sequentially, starting from the first one. This ensures that even if the underlying data is mutated, we're not using stale cursors and potentially getting duplicates or skipping records. If an infinite query's results are ever removed from the queryCache, the pagination restarts at the initial state with only the initial group being requested.
If you only want to actively refetch a subset of all pages, you can pass the refetchPage function to refetch returned from useInfiniteQuery.
const { refetch } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
// only refetch the first page
refetch({ refetchPage: (page, index) => index === 0 })
const { refetch } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
// only refetch the first page
refetch({ refetchPage: (page, index) => index === 0 })
You can also pass this function as part of the 2nd argument (queryFilters) to queryClient.refetchQueries, queryClient.invalidateQueries or queryClient.resetQueries.
Signature
The function is executed for each page, and only pages where this function returns true will be refetched.
By default, the variable returned from getNextPageParam will be supplied to the query function, but in some cases, you may want to override this. You can pass custom variables to the fetchNextPage function which will override the default variable like so:
const fetchProjects = ({ pageParam = 0 }) =>
fetch('/api/projects?cursor=' + pageParam)
const { fetchNextPage } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
// Pass your own page param
const skipToCursor50 = () => fetchNextPage({ pageParam: 50 })
const fetchProjects = ({ pageParam = 0 }) =>
fetch('/api/projects?cursor=' + pageParam)
const { fetchNextPage } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
// Pass your own page param
const skipToCursor50 = () => fetchNextPage({ pageParam: 50 })
Bi-directional lists can be implemented by using the getPreviousPageParam, fetchPreviousPage, hasPreviousPage and isFetchingPreviousPage properties and functions.
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
Sometimes you may want to show the pages in reversed order. If this is case, you can use the select option:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
})
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
})
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
Make sure to always keep the same data structure of pages and pageParams!