DEV Community

Cover image for GraphQL Infinite Scroll
Antonio Moruno Gracia
Antonio Moruno Gracia

Posted on

GraphQL Infinite Scroll

Ever wonder how to enhance your website with an infinite scroll feature that keeps your users endlessly engaged?

In this article, we’re breaking down the process of creating an infinite scroll using GraphQL and React. By the end of this guide, you’ll be able to implement it in your own web projects.

Let’s dive in and unlock the secrets of infinite scrolling with GraphQL and React—it’s easier than you might think!

The Posts List

We are implementing a simple post list to serve as an example for this article. We will use this api to get some dummy posts.

Main Layout

In our case, we want to show the top 100 most rated post. Initially, we don't want to load all the posts at once. It would be more appropiate to load some posts, and when the user scrolls down the least then we load some more, and so on. That's the purpose of infinite scrolling: saving users from an initial full page load.

So, here we have the code that renders the page where we show all the posts.

// src/pages/posts/index.tsx
import Spinner from '~/components/Spinner'

import useLayout from './hooks'
import Post from './Post'
import {
} from './styles'

const Layout = () => {
  const { loading, posts, thresholdElementRef } = useLayout()

  return (
      <PageTitle>Infinite Scroll</PageTitle>
          <Title>100 Most rated posts</Title>
          <Description>Check them all by scrolling down!</Description>
        {loading ? (
          <Spinner />
        ) : (
            {{ id, title }, index) => (
                  index === posts.length - 1 ? thresholdElementRef : undefined

export default Layout
Enter fullscreen mode Exit fullscreen mode

We are using a custom hook useLayout, from where we are extracting three things:

  • loading: Not relevant to this article. Just tells us if the query is still loading.
  • posts: The posts that are being rendered.
  • thresholdElementRef: A reference attached to the element that will serve as a threshold. In our case, that element will be the last post.

Let's take a look at our useLayout.

// src/pages/posts/hooks.tsx
import usePosts from '~/hooks/usePosts'
import useInfiniteScroll from '~/lib/use-infinite-scroll'

const useLayout = () => {
  const { fetchMorePosts, loading, posts } = usePosts()

  const { thresholdElementRef } = useInfiniteScroll({
    fetchNextPage: fetchMorePosts,
    options: { rootMargin: '400px' },

  return { loading, posts, thresholdElementRef }

export default useLayout

Enter fullscreen mode Exit fullscreen mode

Right here, we are creating the thresholdElementRef using
useInfiniteScroll. This library receives the fetchMorePosts function and some options as props. Let's break everything down step by step.

The useInfiniteScroll lib

This library will allow us to obtain a threshold reference, which we will asign to the element that will serve as a threshold.

// src/lib/use-infinite-scroll/index.ts
import useIntersectedElement from '../use-intersected-element'
import { UseInfiniteScrollProps } from './types'

const useInfiniteScroll = <ThresholdElement extends Element = Element>({
}: UseInfiniteScrollProps) => {
  const { thresholdElementRef } = useIntersectedElement<ThresholdElement>({
    callback: fetchNextPage,

  return { thresholdElementRef }

export default useInfiniteScroll
Enter fullscreen mode Exit fullscreen mode

This library just passes the received props to another library: useIntersectedElement.

// src/lib/use-intersected-element/index.ts
import { useEffect, useMemo, useState } from 'react'

import { UseIntersectedElementProps } from './types'

const useIntersectedElement = <ThresholdElement extends Element = Element>({
}: UseIntersectedElementProps) => {
  const [thresholdElement, thresholdElementRef] =
    useState<ThresholdElement | null>(null)

  const observer = useMemo(
    () =>
      new IntersectionObserver(([entry]) => {
        if (!entry.isIntersecting) return

      }, options),
    [callback, options],

  useEffect(() => {
    if (!thresholdElement) return


    return () => {
  }, [observer, thresholdElement])

  return { thresholdElementRef }

export default useIntersectedElement

export type { UseIntersectedElementProps }
Enter fullscreen mode Exit fullscreen mode

Here, we create a state, whose setter is the ref that we've been talking about. Remember that, ultimately, this threshold element will be the last post of the list, as we mentioned before.

Then, we use IntersectionObserver to create an observer, which will execute the callback function received as an argument (remember the fetchMorePosts function in the useLayout hook?). If the entry (the target element) is not been intersected, then we execute nothing.

It can also receive some options. In our case (remember the useLayout hook), we only use the rootMargin: '400px', which increase the size of the root element's bounding box before computing intersections. You can take a look at the documentation in case of any doubts.

Finally, in the useEffect we are observing the threshold element, and unobserving it when the component unmounts.

The fetchMorePosts

What does GraphQL have to do with all of this? Well, here it comes!

// src/hooks/usePosts/index.ts
import { useQuery } from '@apollo/client'
import { useCallback, useMemo } from 'react'

import POSTS from '~/graphql/queries/posts'
import { PostsQuery, PostsQueryVariables } from '~/graphql/types'
import Post from '~/models/post'

import { MAX_NUMBER_OF_POSTS, POSTS_LIMIT } from './constants'

const usePosts = () => {
  const { data, fetchMore, loading } = useQuery<
  >(POSTS, {
    variables: {
      options: { paginate: { limit: POSTS_LIMIT, page: 1 } },

  const posts = useMemo(
    () => (data ? data.posts?.data?.map(Post.fromDto) ?? [] : []),

  const fetchMorePosts = useCallback(() => {
    if (posts.length === MAX_NUMBER_OF_POSTS) return

      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev

        return Object.assign({}, prev, {
          posts: {
            data: [
              ...(prev.posts?.data ?? []),
              ...(fetchMoreResult.posts?.data ?? []),
      variables: {
        options: { paginate: { page: posts.length / POSTS_LIMIT + 1 } },
  }, [fetchMore, posts])

  return { fetchMorePosts, loading, posts }

export default usePosts
Enter fullscreen mode Exit fullscreen mode

We need to use useQuery to fetch the data from the api. It is important that the api supports pagination. Pagination is a process used to divide a large dataset into smaller chunks (pages). This will allow us to request a "page" per request (ultimately allowing the implementation of an infinite scroll). In our case, we will request 10 posts per "page". We will store all those posts in posts.

So, here we have (finally 😅) the fetchMorePosts function. It makes use of the fetchMore function, which allows to send followup queries to our GraphQL server to obtain additional pages.

The behaviour would be the following: If we've reached the maximum number of posts, we stop requesting data. If not, fetchMorePosts will request the next 10 posts, adding them to posts.

You can find more information about GraphQL pagination in the official documentation.


We did it! Now we have an infinite scroll in our posts list.


So, to sum up:

  • We set the threshold reference to the last post that is being rendered (firstly, it will be the 10th, then the 20th, etc.)
  • We set the callback fetchMorePosts to the threshold, and execute it when the threshold is reached.
  • fetchMorePosts will query 10 posts at a time, adding them to the ones that have already been requested, until the limit is reached.

All the project code is available in this Github repository.

Final thoughts

Deciding to use GraphQL shouldn't be about adding infinite scroll. It's a bigger decision. However, GraphQL is a versatile tool that allows us to apply and implement some great things, and infinite scroll is not an exception.

I hope you've enjoyed the article and that it has encouraged you to add it to your projects. Thanks for your time!

Top comments (0)