Telerik blogs

Let’s explore two patterns of pagination in TanStack Query for Vue: paginated queries and infinite queries.

In my previous article on Handling Mutations with TanStack Query for Vue, we wrapped up the read-write side of TanStack Query. Now I want to circle back to the read side and tackle one of the trickiest UX problems we as developers run into all the time: paginating through a long list of data.

I’ll be honest with you. Pagination is one of those things that sounds simple until you start implementing it. Page boundaries, cached pages getting out of sync, the awkward jump when filters change, “load more” vs. traditional page numbers vs. infinite scroll.

TanStack Query has a few tools that take a lot of the pain out of all of this, and we’re going to walk through them today.

Two Flavors of Pagination

There are really two patterns we run into in real applications, and they correspond to two different APIs in TanStack Query.

The first is paginated queries: The user clicks “next” and “previous” buttons (or a numbered set of pages), and we fetch exactly one page at a time. Each page replaces the previous one in the UI. This is what you’d build for an admin table or a search result with explicit pagination controls.

The second is infinite queries: The user scrolls or clicks “load more” and we append the next page to what’s already on screen. The data accumulates rather than replacing. This is what you’d build for a social feed, a chat history or any “show me more” interaction.

The first uses regular useQuery with a small twist. The second uses useInfiniteQuery, which is its own dedicated composable. Let’s tackle each in turn.

Standard Pagination with useQuery

Let’s start with the simpler case. Suppose we have an API endpoint that takes a page query parameter and returns a fixed number of users per page.

UserList.vue

<script setup>
import { ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'

const page = ref(1)

const fetchUsers = async (pageNumber) => {
  const response = await fetch(`https://myapp.com/users?page=${pageNumber}`)
  if (!response.ok) {
    throw new Error('Failed to fetch users')
  }
  return response.json()
}

const { data, isPending, isFetching, isPlaceholderData } = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetchUsers(page.value),
  placeholderData: (previousData) => previousData,
})
</script>

<template>
  <p v-if="isPending">Loading users...</p>
  <ul v-else>
    <li v-for="user in data.users" :key="user.id">{{ user.name }}</li>
  </ul>

  <button :disabled="page === 1" @click="page--">Previous</button>
  <button
    :disabled="isPlaceholderData || !data?.hasMore"
    @click="page++"
  >
    Next
  </button>

  <span v-if="isFetching"> Fetching...</span>
</template>

Let’s break it down. There are a couple of nice tricks happening here.

First, notice that our queryKey includes the page ref. Because TanStack Query treats different query keys as different queries, each page is cached separately. When the user navigates back to page 1 after looking at page 2, page 1’s data is already cached and shows up instantly.

Note: Passing a ref as part of the key is perfectly acceptable, and a great way to make your keys dynamic!

Second, and this is the really nice part, we’re using placeholderData: (previousData) => previousData. Without this option, every time the page changes we’d see the loading state flash before the new data appears (because useQuery treats each new key as a brand new query). With it, while the new page is loading, TanStack Query keeps showing the data from the previous page. The result is a much smoother feel. The list stays put, the “Next” button briefly disables, and then the rows update.

The isPlaceholderData flag tells us whether what we’re currently rendering is the previous page’s data being held over. We use it to disable the “Next” button so the user can’t fire requests faster than they can resolve.

This pattern works beautifully for traditional paginated tables. The cache means that going back to a previous page is instantaneous, and placeholderData smooths out the transitions in either direction.

In the above example, we assume a hasMore property returned from the API that tells us if there’s a next page, this could also be a totalItems that we check against the currentPage x itemsPerPage depending on your API.

Cursor vs. Offset

Before we move on, a quick word on the API side. The example above uses an offset-based API (?page=N). Plenty of modern APIs use cursor-based pagination instead, where each page returns a nextCursor value that you pass back to fetch the next one.

For standard useQuery, the only thing that changes is what you put in your queryKey. Instead of a page number, you’d track the current cursor as a ref, and use it both in the key and in the fetch function. Everything else works the same way.

Cursor pagination really comes into its own with useInfiniteQuery, which we’re getting to next.

Infinite Queries with useInfiniteQuery

Now let’s tackle the “load more” pattern. Same data, different UX. Instead of replacing one page with another, we want each new page to be appended to the list.

Feed.vue

<script setup>
import { useInfiniteQuery } from '@tanstack/vue-query'

const fetchPosts = async ({ pageParam }) => {
  const response = await fetch(`https://myapp.com/posts?page=${pageParam}`)
  if (!response.ok) {
    throw new Error('Failed to fetch posts')
  }
  return response.json()
}

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isPending,
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) => {
    return lastPage.hasMore ? allPages.length + 1 : undefined
  },
})
</script>

<template>
  <p v-if="isPending">Loading...</p>
  <template v-else>
    <ul>
      <template v-for="(page, index) in data.pages" :key="index">
        <li v-for="post in page.posts" :key="post.id">{{ post.title }}</li>
      </template>
    </ul>

    <button
      v-if="hasNextPage"
      :disabled="isFetchingNextPage"
      @click="fetchNextPage"
    >
      {{ isFetchingNextPage ? 'Loading more...' : 'Load more' }}
    </button>
  </template>
</template>

There’s a lot to unpack here. Let’s break it down.

The most important new property is getNextPageParam. This function receives the last page that was fetched (and the entire array of pages so far) and returns the parameter that should be used for the next fetch. If it returns undefined, TanStack Query knows there are no more pages, and hasNextPage becomes false.

In the example above, our backend returns a hasMore boolean on each page. We use that to decide whether there’s a next page, and we use the count of pages we already have to compute the next page number.

The shape of data is also different from a regular useQuery. Instead of getting the response directly, we get an object with two properties:

  • data.pages: An array of every page we’ve fetched so far, in order
  • data.pageParams: An array of the parameters we used to fetch each page

This is why our template loops through data.pages and then over the posts within each page. If you’d rather have a flat array, you can transform it with a select option or just flat-map in a computed:

import { computed } from 'vue'
const allPosts = computed(() => data.value?.pages.flatMap((page) => page.posts) ?? [])

Notice how fetchNextPage is what we call to actually load the next page. It’s a function returned by the composable, similar to refetch but specifically for this use case. The accompanying isFetchingNextPage flag lets us show a “loading more” state on the button without flipping the whole list back to a loading state.

Cursor-Based Infinite Queries

If your API returns a cursor instead of a page number, the structure stays the same, you just use the cursor in getNextPageParam:

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts(pageParam),
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})

Honestly, cursor-based APIs play more naturally with infinite queries than offset-based ones do. The cursor encodes “where to pick up from” without any of the off-by-one issues you can hit with page numbers when items are inserted or removed mid-scroll.

‘Load More’ vs. Infinite Scroll

The example above gives the user a “Load more” button. I’d encourage you to consider that pattern as a default, even if your designer is asking for endless infinite scroll. A button is accessible, gives the user explicit control and doesn’t surprise them with extra fetches when they’re trying to reach the footer.

That said, if you do need infinite scroll, the wiring is straightforward. Use an IntersectionObserver (or a composable like VueUse’s useIntersectionObserver) on a sentinel element near the bottom of the list, and call fetchNextPage() when it becomes visible. The TanStack Query side doesn’t change at all.

<script setup>
import { ref } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

const sentinel = ref(null)

useIntersectionObserver(sentinel, ([entry]) => {
  if (entry.isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
    fetchNextPage()
  }
})
</script>

<template>
  <!-- list rendering up here... -->
  <div ref="sentinel"></div>
</template>

Just please, please, also expose a manual button somewhere in case the observer fails or the user is using a screen reader.

A Word on Refetching Infinite Queries

One thing that catches people off guard with infinite queries: when an infinite query gets refetched (because of window focus, invalidation, etc.), TanStack Query refetches all the pages that have been loaded so far, in order. This keeps the data consistent and makes sure you don’t end up with a frankenstein list of fresh pages 1 and 2 alongside stale pages 3 and 4.

That’s usually exactly what you want, but it does mean that an infinite query that has loaded twenty pages will fire 20 network requests on a refetch. Keep that in mind for high-volume feeds, and consider adjusting staleTime or refetchOnWindowFocus to prevent expensive surprise refetches.

Prefetching the Next Page

One of my favorite tricks with paginated queries is prefetching. Once a page loads, you can quietly fire off the request for the next page in the background, so by the time the user clicks “Next” the data is already cached and shows up instantly.

import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { watchEffect } from 'vue'

const queryClient = useQueryClient()

const { data } = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetchUsers(page.value),
})

watchEffect(() => {
  if (data.value?.hasMore) {
    queryClient.prefetchQuery({
      queryKey: ['users', page.value + 1],
      queryFn: () => fetchUsers(page.value + 1),
    })
  }
})

prefetchQuery does exactly what useQuery does, but without subscribing a component to the result. It just warms the cache. When useQuery later mounts with that key, it finds the data already there and skips the loading state entirely.

I tend to use this sparingly. It costs an extra request per page view, so on a long table where most users only look at the first page, prefetching aggressively wastes bandwidth. But for a stepper or wizard where the user almost always continues forward, it’s a really nice quality-of-life touch.

Combining Pagination with Filters

One last gotcha worth calling out. If your list also has filters (search input, status dropdown, sort order), make sure every filter is included in your queryKey. Otherwise TanStack Query will hand you cached data from a different filter set and you’ll spend an afternoon wondering why the search isn’t working.

const { data } = useQuery({
  queryKey: ['users', page, search, sort],
  queryFn: () => fetchUsers(page.value, search.value, sort.value),
  placeholderData: (previousData) => previousData,
})

The same applies to infinite queries. Each unique combination of filters produces its own infinite query, and switching filters resets the accumulated pages cleanly. That’s the behavior you want.

Wrapping Up

Pagination is one of those problems where TanStack Query’s caching really shines. With useQuery and placeholderData you get smooth back-and-forth navigation through pages with cached data showing up instantly. With useInfiniteQuery and getNextPageParam you get an accumulating feed pattern with a clean API for both offset and cursor based backends.

The official paginated queries guide and the infinite queries guide are both worth a read once you have the basics down, especially for things like bidirectional infinite queries (loading both up and down, useful for chat) and removing or replacing items in the middle of the cached pages.

Happy paginating!


About the Author

Marina Mosti

Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.

Related Posts

Comments

Comments are disabled in preview mode.