This episode teaches incremental data fetching: useInfiniteQuery with getNextPageParam for a Load More button, and simple page-number-based pagination with placeholderData and keepPreviousData to keep the UI smooth.

A todo list with hundreds of items, a news feed that keeps growing, or thousands of comments — data like this can't possibly be fetched all at once. Two common approaches: pagination with page numbers, and infinite scroll / Load More, which loads the next page when the user needs it.
Episode 9 covers both: useInfiniteQuery for the Load More pattern, and simple pagination with keepPreviousData so page changes don't show a flickering loading screen.
The most straightforward approach: store the page number in useState, then make it part of the queryKey:
const [page, setPage] = useState(1)
const { data, isPending } = useQuery({
queryKey: ["todos", "page", page],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=10`).then(
(res) => res.json()
),
})
return (
<div>
{data?.map((todo) => <p key={todo.id}>{todo.title}</p>)}
<button onClick={() => setPage((p) => Math.max(1, p - 1))}>Sebelumnya</button>
<button onClick={() => setPage((p) => p + 1)}>Berikutnya</button>
</div>
)Each page has its own cache because page is inside the queryKey. When you change pages, TanStack Query shows a full loading state — this is where keepPreviousData comes to the rescue.
To prevent flicker when changing pages, keep the previous page's data as a placeholder:
import { keepPreviousData } from "@tanstack/react-query"
const { data, isFetching } = useQuery({
queryKey: ["todos", "page", page],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=10`).then(
(res) => res.json()
),
placeholderData: keepPreviousData,
})placeholderData: keepPreviousData keeps the previous page's data visible while the new page is being fetched. isFetching is true during that process — it can be used to show a subtle indicator without replacing the content. The result is that page navigation feels instant.
For the Load More pattern, use useInfiniteQuery. The difference from useQuery: data is collected in the form of pages (an array of pages) plus pageParams, and you tell the library how to determine the next page via getNextPageParam:
import { useInfiniteQuery } from "@tanstack/react-query"
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["todos", "infinite"],
queryFn: ({ pageParam }) =>
fetch(
`https://jsonplaceholder.typicode.com/todos?_page=${pageParam}&_limit=10`
).then((res) => res.json()),
initialPageParam: 1,
getNextPageParam: (lastPage, _pages, lastPageParam) =>
lastPage.length === 10 ? lastPageParam + 1 : undefined,
})getNextPageParam receives the last page and returns the next page — or undefined to signal there are no more pages. initialPageParam: 1 sets the first page. useInfiniteQuery calls queryFn with the automatically computed pageParam.
Rendering all pages and a button to load the next one:
function Todos() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["todos", "infinite"],
queryFn: ({ pageParam }) =>
fetch(
`https://jsonplaceholder.typicode.com/todos?_page=${pageParam}&_limit=10`
).then((res) => res.json()),
initialPageParam: 1,
getNextPageParam: (lastPage, _pages, lastPageParam) =>
lastPage.length === 10 ? lastPageParam + 1 : undefined,
})
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.map((todo) => (
<p key={todo.id}>{todo.title}</p>
))}
</div>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
{isFetchingNextPage ? "Memuat..." : hasNextPage ? "Load More" : "Tidak ada lagi"}
</button>
</div>
)
}data.pages is an array containing all the pages already loaded. fetchNextPage() fetches the next page, hasNextPage is false when getNextPageParam returns undefined, and isFetchingNextPage indicates the process is running.
If your navigation pattern is bidirectional — a chat or feed that can scroll upward — getPreviousPageParam provides the reverse direction:
getPreviousPageParam: (firstPage, _pages, firstPageParam) =>
firstPageParam > 1 ? firstPageParam - 1 : undefined,getPreviousPageParam works like the backward version of getNextPageParam, with fetchPreviousPage as its method counterpart. getNextPageParam and getPreviousPageParam can be used together for a two-way infinite query.
Tip
For automatic infinite scroll (without a button), combine fetchNextPage with an IntersectionObserver on a sentinel element at the bottom of the list. TanStack Query doesn't provide scroll-event infinite scroll — that's your responsibility — but the required methods are all there.
Episode 9 gave you two incremental data fetching patterns common in real applications: page-number pagination with keepPreviousData for a smooth UX, and infinite queries with a Load More button controlled by getNextPageParam.
Key takeaways:
queryKey and queryFn.placeholderData: keepPreviousData prevents flicker when changing pages.useInfiniteQuery collects data in the form of pages.getNextPageParam determines the next page or undefined.fetchNextPage and hasNextPage control the Load More button.getPreviousPageParam for two-way navigation.In the next episode, episode 10, we will discuss prefetching and data seeding — prefetchQuery to seed data before render, plus initialData and placeholderData for instant rendering. These are important techniques for an ultra-responsive UX.