Infinite scrolling in Apollo GraphQL and GraphQL-ruby

Infinite scrolling is a UX pattern that loads data continuously as users scroll, eliminating the need for traditional pagination. This post covers how to implement infinite scrolling with Apollo GraphQL on the frontend and GraphQL-ruby on the backend.

What is Infinite Scrolling?

Infinite scrolling automatically loads more content as the user scrolls down the page, providing a seamless browsing experience without pagination controls.

Backend Setup (GraphQL-ruby)

1. Implement Cursor-Based Pagination

Use cursor-based pagination for efficient infinite scrolling:

# app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :posts, Types::PostType.connection_type, null: false do
      argument :after, String, required: false
    end

    def posts(after: nil)
      posts = Post.order(created_at: :desc)
      posts = posts.where('id > ?', decode_cursor(after)) if after
      posts.limit(20)
    end

    private

    def decode_cursor(cursor)
      Base64.decode64(cursor).to_i
    rescue
      nil
    end
  end
end

2. Use Connection Types

GraphQL-ruby provides connection types for pagination:

# app/graphql/types/post_type.rb
module Types
  class PostType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :content, String, null: false
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
  end
end

3. Add Page Info

Return pagination metadata:

# The connection type automatically includes:
# - edges: Array of items
# - pageInfo: { hasNextPage, hasPreviousPage, startCursor, endCursor }

Frontend Setup (Apollo GraphQL)

1. Install Dependencies

npm install @apollo/client react-infinite-scroll-component
# or
yarn add @apollo/client react-infinite-scroll-component

2. Create GraphQL Query

Define your query with pagination:

import { gql } from '@apollo/client'

export const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          content
          createdAt
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`

3. Implement Infinite Scroll Component

Create a component with infinite scrolling:

import { useQuery } from '@apollo/client'
import InfiniteScroll from 'react-infinite-scroll-component'
import { GET_POSTS } from './queries'

function PostList() {
  const { data, fetchMore, loading } = useQuery(GET_POSTS, {
    variables: { first: 20 },
    notifyOnNetworkStatusChange: true,
  })

  const loadMore = () => {
    if (data?.posts?.pageInfo?.hasNextPage) {
      fetchMore({
        variables: {
          after: data.posts.pageInfo.endCursor,
        },
        updateQuery: (prev, { fetchMoreResult }) => {
          if (!fetchMoreResult) return prev

          return {
            ...prev,
            posts: {
              ...fetchMoreResult.posts,
              edges: [
                ...prev.posts.edges,
                ...fetchMoreResult.posts.edges,
              ],
            },
          }
        },
      })
    }
  }

  if (loading && !data) {
    return <div>Loading...</div>
  }

  const posts = data?.posts?.edges || []
  const hasMore = data?.posts?.pageInfo?.hasNextPage || false

  return (
    <InfiniteScroll
      dataLength={posts.length}
      next={loadMore}
      hasMore={hasMore}
      loader={<div>Loading more posts...</div>}
      endMessage={<div>No more posts</div>}
    >
      {posts.map(({ node: post }) => (
        <div key={post.id} className="post">
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </div>
      ))}
    </InfiniteScroll>
  )
}

export default PostList

Alternative: Using Apollo's fetchMore

You can also use Apollo's built-in fetchMore without a library:

import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'

function PostList() {
  const [posts, setPosts] = useState([])
  const [hasMore, setHasMore] = useState(true)
  const [cursor, setCursor] = useState(null)

  const { data, fetchMore, loading } = useQuery(GET_POSTS, {
    variables: { first: 20 },
  })

  useEffect(() => {
    if (data?.posts) {
      setPosts(data.posts.edges.map(edge => edge.node))
      setHasMore(data.posts.pageInfo.hasNextPage)
      setCursor(data.posts.pageInfo.endCursor)
    }
  }, [data])

  const loadMore = async () => {
    if (!hasMore || loading) return

    const { data: newData } = await fetchMore({
      variables: {
        after: cursor,
      },
    })

    if (newData?.posts) {
      const newPosts = newData.posts.edges.map(edge => edge.node)
      setPosts([...posts, ...newPosts])
      setHasMore(newData.posts.pageInfo.hasNextPage)
      setCursor(newData.posts.pageInfo.endCursor)
    }
  }

  useEffect(() => {
    const handleScroll = () => {
      if (
        window.innerHeight + window.scrollY >=
        document.documentElement.offsetHeight - 1000
      ) {
        loadMore()
      }
    }

    window.addEventListener('scroll', handleScroll)
    return () => window.removeEventListener('scroll', handleScroll)
  }, [posts, hasMore, loading])

  return (
    <div>
      {posts.map(post => (
        <div key={post.id} className="post">
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </div>
      ))}
      {loading && <div>Loading more...</div>}
      {!hasMore && <div>No more posts</div>}
    </div>
  )
}

Backend: Cursor Implementation

Simple ID-Based Cursor

# app/graphql/types/query_type.rb
def posts(after: nil, first: 20)
  relation = Post.order(id: :desc)
  relation = relation.where('id < ?', decode_cursor(after)) if after
  relation.limit(first)
end

private

def decode_cursor(cursor)
  return nil unless cursor
  Base64.urlsafe_decode64(cursor).to_i
rescue
  nil
end

def encode_cursor(id)
  Base64.urlsafe_encode64(id.to_s)
end

Timestamp-Based Cursor

For better performance with large datasets:

def posts(after: nil, first: 20)
  relation = Post.order(created_at: :desc, id: :desc)

  if after
    timestamp, id = decode_cursor(after)
    relation = relation.where(
      '(created_at, id) < (?, ?)',
      timestamp,
      id
    )
  end

  relation.limit(first)
end

private

def decode_cursor(cursor)
  decoded = Base64.urlsafe_decode64(cursor)
  timestamp, id = decoded.split(':')
  [Time.parse(timestamp), id.to_i]
rescue
  [nil, nil]
end

Best Practices

  1. Use cursor-based pagination: More efficient than offset-based
  2. Limit page size: Keep pages small (10-20 items)
  3. Show loading state: Indicate when more content is loading
  4. Handle errors: Provide error handling for failed loads
  5. Optimize queries: Use database indexes on cursor fields

Performance Considerations

Database Indexing

Ensure proper indexes:

# db/migrate/xxx_add_indexes_to_posts.rb
add_index :posts, [:created_at, :id]

Query Optimization

Limit fields in queries:

query GetPosts($first: Int!, $after: String) {
  posts(first: $first, after: $after) {
    edges {
      node {
        id
        title
        # Only fetch needed fields
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Error Handling

Handle errors gracefully:

const { data, error, fetchMore, loading } = useQuery(GET_POSTS, {
  variables: { first: 20 },
})

if (error) {
  return <div>Error loading posts: {error.message}</div>
}

const loadMore = async () => {
  try {
    await fetchMore({
      variables: { after: cursor },
    })
  } catch (err) {
    console.error('Error loading more posts:', err)
    // Show error message to user
  }
}

Conclusion

Infinite scrolling with Apollo GraphQL and GraphQL-ruby provides a smooth user experience for browsing large datasets. By implementing cursor-based pagination on the backend and using Apollo's fetchMore on the frontend, you can create efficient infinite scroll implementations that scale well with large amounts of data.

Infinite scrolling in Apollo GraphQL and GraphQL-ruby - Abhay Nikam