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
- Use cursor-based pagination: More efficient than offset-based
- Limit page size: Keep pages small (10-20 items)
- Show loading state: Indicate when more content is loading
- Handle errors: Provide error handling for failed loads
- 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.