usePaginationFragment Hook
Loading more items with loadNext, loadPrevious, and refetch
usePaginationFragment Hook
Master efficient data loading patterns with the usePaginationFragment hook and free flashcards to reinforce your understanding. This lesson covers the hook's API, cursor-based pagination mechanics, connection patterns, and practical implementation strategiesβessential concepts for building scalable Relay applications that handle large datasets gracefully.
Welcome to Pagination Mastery π»
The usePaginationFragment hook is Relay's solution to one of the most common challenges in modern web applications: efficiently loading and displaying large lists of data. Rather than fetching thousands of items at once (which would overwhelm both your server and user's browser), this hook enables incremental loading where you fetch small "pages" of data on demand.
Think of it like reading a book πβyou don't need to load every page into memory at once. You read one page, then turn to the next when you're ready. Similarly, usePaginationFragment lets users scroll through data, loading more items only when needed.
Core Concepts: The Pagination Architecture ποΈ
What Makes usePaginationFragment Special?
Unlike the basic useFragment hook, usePaginationFragment provides three critical capabilities:
- Automatic cursor management - Keeps track of where you are in the dataset
- Built-in loading states - Tells you when more data is being fetched
- Bi-directional pagination - Load data forward ("load more") or backward ("load previous")
The hook returns an object with these key properties:
| Property | Type | Purpose |
|---|---|---|
data | Object | The actual paginated data |
loadNext | Function | Fetch the next page of results |
loadPrevious | Function | Fetch the previous page of results |
hasNext | Boolean | Are there more items after current page? |
hasPrevious | Boolean | Are there items before current page? |
isLoadingNext | Boolean | Currently fetching next page? |
isLoadingPrevious | Boolean | Currently fetching previous page? |
refetch | Function | Restart pagination from beginning |
The Connection Pattern: GraphQL's Pagination Standard π
Relay's pagination follows the GraphQL Cursor Connections Specification, a standardized pattern that looks like this:
Connection Structure:
βββββββββββββββββββββββββββββββββββββββ
β Connection β
βββββββββββββββββββββββββββββββββββββββ€
β edges: [ β
β { β
β cursor: "opaque-string-1" β β Position marker
β node: { actual data } β β Your item
β }, β
β { β
β cursor: "opaque-string-2" β
β node: { actual data } β
β } β
β ] β
β pageInfo: { β
β hasNextPage: true, β β More items ahead?
β hasPreviousPage: false, β β Items before?
β startCursor: "opaque-string-1" β β First cursor
β endCursor: "opaque-string-2" β β Last cursor
β } β
βββββββββββββββββββββββββββββββββββββββ
Why edges and nodes? This structure enables efficient pagination without offset-based counting (which becomes slow with large datasets). The cursor is like a bookmark πβit marks an exact position in your dataset that remains stable even if items are added or removed.
Defining a Pagination Fragment
To use usePaginationFragment, you first define a fragment with special @connection and @refetchable directives:
fragment UserList_users on Query
@refetchable(queryName: "UserListPaginationQuery")
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
) {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges {
node {
id
name
email
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Breaking it down:
- @refetchable: Generates a query for fetching more data
- @argumentDefinitions: Declares pagination variables (count = how many, cursor = where to start)
- @connection: Tells Relay to manage this as a paginated connection with a unique key
- first: $count, after: $cursor: Standard forward pagination arguments
π‘ Pro tip: The connection key ("UserList_users") must be unique across your app. Relay uses this to cache and update the connection correctly.
Using the Hook in Your Component π―
Here's how you actually use usePaginationFragment in a React component:
import { usePaginationFragment } from 'react-relay';
import { graphql } from 'relay-runtime';
function UserList({ queryRef }) {
const {
data,
loadNext,
hasNext,
isLoadingNext
} = usePaginationFragment(
graphql`
fragment UserList_users on Query
@refetchable(queryName: "UserListPaginationQuery")
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
) {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges {
node {
id
name
email
}
}
}
}
`,
queryRef
);
const loadMore = () => {
if (hasNext && !isLoadingNext) {
loadNext(10); // Load 10 more items
}
};
return (
<div>
{data.users.edges.map(({ node }) => (
<div key={node.id}>
<h3>{node.name}</h3>
<p>{node.email}</p>
</div>
))}
{hasNext && (
<button onClick={loadMore} disabled={isLoadingNext}>
{isLoadingNext ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Key observations:
- Guard your loadNext calls: Always check
hasNextand!isLoadingNextbefore callingloadNext()to prevent unnecessary requests - Pass the count:
loadNext(10)fetches 10 more items. This can be different from your initial page size - Access data through edges: The data is at
data.users.edges[].node, not directly atdata.users
Infinite Scroll Implementation π
A common pattern is loading more data automatically as users scroll:
import { useEffect, useRef } from 'react';
import { usePaginationFragment } from 'react-relay';
function InfiniteUserList({ queryRef }) {
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
UserListFragment,
queryRef
);
const observerTarget = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
loadNext(20);
}
},
{ threshold: 0.5 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [hasNext, isLoadingNext, loadNext]);
return (
<div>
{data.users.edges.map(({ node }) => (
<UserCard key={node.id} user={node} />
))}
{/* Trigger element for intersection observer */}
<div ref={observerTarget} style={{ height: '20px' }} />
{isLoadingNext && <div>Loading more...</div>}
</div>
);
}
This uses the IntersectionObserver API to detect when the user scrolls near the bottom, automatically triggering loadNext().
Example 1: Basic Forward Pagination with Load More Button π
Let's build a complete example showing a list of blog posts with a "Load More" button:
import { graphql, usePaginationFragment } from 'react-relay';
const BlogPostListFragment = graphql`
fragment BlogPostList_query on Query
@refetchable(queryName: "BlogPostListPaginationQuery")
@argumentDefinitions(
count: { type: "Int", defaultValue: 5 }
cursor: { type: "String" }
) {
posts(first: $count, after: $cursor)
@connection(key: "BlogPostList_posts") {
edges {
node {
id
title
excerpt
publishedAt
author {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
function BlogPostList({ queryRef }) {
const {
data,
loadNext,
hasNext,
isLoadingNext
} = usePaginationFragment(BlogPostListFragment, queryRef);
return (
<div className="blog-post-list">
<h2>Recent Posts</h2>
{data.posts.edges.map(({ node }) => (
<article key={node.id} className="post-card">
<h3>{node.title}</h3>
<p className="meta">
By {node.author.name} β’ {node.publishedAt}
</p>
<p>{node.excerpt}</p>
</article>
))}
{hasNext && (
<button
onClick={() => loadNext(5)}
disabled={isLoadingNext}
className="load-more-btn"
>
{isLoadingNext ? (
<span>β³ Loading...</span>
) : (
<span>π Load More Posts</span>
)}
</button>
)}
{!hasNext && data.posts.edges.length > 0 && (
<p className="end-message">β
You've reached the end!</p>
)}
</div>
);
}
Why this works well:
- Clear loading state: Button shows different text/icon when fetching
- Disabled during load: Prevents duplicate requests
- End-of-list feedback: Users know when they've seen everything
- Small page size: Starting with 5 posts keeps initial load fast
Example 2: Bidirectional Pagination (Load Previous & Next) β¬ οΈβ‘οΈ
Sometimes you need to navigate both forward and backward through data, like a calendar or timeline:
import { graphql, usePaginationFragment } from 'react-relay';
const TimelineFragment = graphql`
fragment Timeline_user on User
@refetchable(queryName: "TimelinePaginationQuery")
@argumentDefinitions(
firstCount: { type: "Int" }
afterCursor: { type: "String" }
lastCount: { type: "Int" }
beforeCursor: { type: "String" }
) {
activities(
first: $firstCount
after: $afterCursor
last: $lastCount
before: $beforeCursor
) @connection(key: "Timeline_activities") {
edges {
node {
id
type
description
timestamp
}
}
}
}
`;
function Timeline({ userRef }) {
const {
data,
loadNext,
loadPrevious,
hasNext,
hasPrevious,
isLoadingNext,
isLoadingPrevious
} = usePaginationFragment(TimelineFragment, userRef);
return (
<div className="timeline">
{/* Load older items */}
{hasPrevious && (
<button
onClick={() => loadPrevious(10)}
disabled={isLoadingPrevious}
className="nav-btn nav-previous"
>
{isLoadingPrevious ? 'β³' : 'β¬οΈ'} Load Earlier Activities
</button>
)}
{/* Activity list */}
<div className="timeline-items">
{data.activities.edges.map(({ node }) => (
<div key={node.id} className="timeline-item">
<span className="timestamp">{node.timestamp}</span>
<span className="type-badge">{node.type}</span>
<p>{node.description}</p>
</div>
))}
</div>
{/* Load newer items */}
{hasNext && (
<button
onClick={() => loadNext(10)}
disabled={isLoadingNext}
className="nav-btn nav-next"
>
{isLoadingNext ? 'β³' : 'β¬οΈ'} Load Recent Activities
</button>
)}
</div>
);
}
Note: For bidirectional pagination, your GraphQL schema must support both first/after (forward) and last/before (backward) arguments on the connection.
Example 3: Pagination with Filtering and Refetch π
Real applications often need to paginate filtered data and reset pagination when filters change:
import { useState } from 'react';
import { graphql, usePaginationFragment } from 'react-relay';
const ProductListFragment = graphql`
fragment ProductList_query on Query
@refetchable(queryName: "ProductListPaginationQuery")
@argumentDefinitions(
count: { type: "Int", defaultValue: 12 }
cursor: { type: "String" }
category: { type: "String" }
minPrice: { type: "Float" }
maxPrice: { type: "Float" }
) {
products(
first: $count
after: $cursor
category: $category
minPrice: $minPrice
maxPrice: $maxPrice
) @connection(key: "ProductList_products") {
edges {
node {
id
name
price
category
imageUrl
}
}
}
}
`;
function ProductList({ queryRef }) {
const [filters, setFilters] = useState({
category: null,
minPrice: null,
maxPrice: null
});
const {
data,
loadNext,
hasNext,
isLoadingNext,
refetch
} = usePaginationFragment(ProductListFragment, queryRef);
const applyFilters = (newFilters) => {
setFilters(newFilters);
// Refetch restarts pagination with new variables
refetch(
{
count: 12,
...newFilters
},
{ fetchPolicy: 'store-and-network' }
);
};
return (
<div className="product-catalog">
<div className="filters">
<select
onChange={(e) => applyFilters({
...filters,
category: e.target.value || null
})}
>
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<input
type="number"
placeholder="Min Price"
onChange={(e) => applyFilters({
...filters,
minPrice: parseFloat(e.target.value) || null
})}
/>
<input
type="number"
placeholder="Max Price"
onChange={(e) => applyFilters({
...filters,
maxPrice: parseFloat(e.target.value) || null
})}
/>
</div>
<div className="product-grid">
{data.products.edges.map(({ node }) => (
<div key={node.id} className="product-card">
<img src={node.imageUrl} alt={node.name} />
<h4>{node.name}</h4>
<p className="price">${node.price}</p>
<span className="category">{node.category}</span>
</div>
))}
</div>
{hasNext && (
<button
onClick={() => loadNext(12)}
disabled={isLoadingNext}
>
{isLoadingNext ? 'Loading...' : 'Show More Products'}
</button>
)}
</div>
);
}
Key pattern: When filter values change, call refetch() with new variables. This:
- Clears the existing connection
- Fetches from the beginning with new filters
- Resets
hasNext,hasPrevious, and cursor state
π‘ Performance tip: The fetchPolicy: 'store-and-network' option shows cached data immediately while fetching fresh data in the background.
Example 4: Optimistic Pagination with Suspense Boundaries π
For the smoothest UX, combine usePaginationFragment with React Suspense to handle loading states elegantly:
import { Suspense } from 'react';
import { graphql, usePaginationFragment } from 'react-relay';
const CommentListFragment = graphql`
fragment CommentList_post on Post
@refetchable(queryName: "CommentListPaginationQuery")
@argumentDefinitions(
count: { type: "Int", defaultValue: 20 }
cursor: { type: "String" }
) {
comments(first: $count, after: $cursor)
@connection(key: "CommentList_comments") {
edges {
node {
id
text
createdAt
author {
name
avatarUrl
}
}
}
}
}
`;
function CommentList({ postRef }) {
const {
data,
loadNext,
hasNext,
isLoadingNext
} = usePaginationFragment(CommentListFragment, postRef);
return (
<div className="comments-section">
<h3>π¬ Comments ({data.comments.edges.length})</h3>
{data.comments.edges.map(({ node }) => (
<div key={node.id} className="comment">
<img
src={node.author.avatarUrl}
alt={node.author.name}
className="avatar"
/>
<div className="comment-content">
<strong>{node.author.name}</strong>
<span className="timestamp">{node.createdAt}</span>
<p>{node.text}</p>
</div>
</div>
))}
{hasNext && (
<Suspense fallback={<LoadingSpinner />}>
<LoadMoreButton
onClick={() => loadNext(20)}
isLoading={isLoadingNext}
/>
</Suspense>
)}
</div>
);
}
function LoadMoreButton({ onClick, isLoading }) {
return (
<button
onClick={onClick}
disabled={isLoading}
className="load-more-comments"
>
{isLoading ? (
<span>β³ Loading comments...</span>
) : (
<span>π Load More Comments</span>
)}
</button>
);
}
function LoadingSpinner() {
return (
<div className="spinner">
<div className="spinner-circle"></div>
<p>Loading...</p>
</div>
);
}
// Usage in parent component
function PostPage() {
return (
<Suspense fallback={<PageLoader />}>
<PostContent />
<Suspense fallback={<CommentsSkeleton />}>
<CommentList postRef={postRef} />
</Suspense>
</Suspense>
);
}
The nested <Suspense> boundaries ensure that:
- Initial page load shows a full-page loader
- Comments section has its own loading state
- Pagination loading doesn't block the entire UI
Common Mistakes and How to Avoid Them β οΈ
Mistake 1: Forgetting the @connection Directive
β Wrong:
fragment UserList_query on Query @refetchable(queryName: "UsersPagination") {
users(first: $count, after: $cursor) {
edges {
node { id name }
}
}
}
β Correct:
fragment UserList_query on Query @refetchable(queryName: "UsersPagination") {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges {
node { id name }
}
}
}
Why it matters: Without @connection, Relay won't properly merge new pages into existing data. Each loadNext() call would replace the previous data instead of appending to it.
Mistake 2: Not Checking hasNext Before Loading
β Wrong:
<button onClick={() => loadNext(10)}>
Load More
</button>
β Correct:
{hasNext && (
<button
onClick={() => loadNext(10)}
disabled={isLoadingNext}
>
{isLoadingNext ? 'Loading...' : 'Load More'}
</button>
)}
Why it matters: Calling loadNext() when hasNext is false wastes network requests and can confuse users.
Mistake 3: Using Wrong Fragment Type
β Wrong: Passing a non-paginated fragment to usePaginationFragment
// This fragment lacks @refetchable and @connection
const fragment = graphql`
fragment UserList_query on Query {
users { id name }
}
`;
const { data } = usePaginationFragment(fragment, ref); // β Error!
β Correct: Always ensure your fragment has both required directives:
const fragment = graphql`
fragment UserList_query on Query
@refetchable(queryName: "UserListPagination")
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
) {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges { node { id name } }
}
}
`;
Mistake 4: Mutating the Connection Manually
β Wrong:
// Trying to manually add items to the connection
data.users.edges.push({ node: newUser }); // β Don't do this!
β Correct: Use Relay mutations with updater functions:
commitMutation(environment, {
mutation: CreateUserMutation,
variables: { input: newUserData },
updater: (store) => {
const newUser = store.getRootField('createUser').getLinkedRecord('user');
const connection = store.get('client:root:UserList_users');
const edge = store.create('temp-id', 'UserEdge');
edge.setLinkedRecord(newUser, 'node');
connection.setLinkedRecords(
[edge, ...connection.getLinkedRecords('edges')],
'edges'
);
}
});
Why it matters: Relay manages the connection cache internally. Direct mutations break reactivity and cause data inconsistencies.
Mistake 5: Incorrect Cursor Type or Missing pageInfo
β Wrong:
fragment UserList_query on Query
@refetchable(queryName: "UserListPagination") {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges {
node { id name }
}
# Missing pageInfo!
}
}
β Correct:
fragment UserList_query on Query
@refetchable(queryName: "UserListPagination") {
users(first: $count, after: $cursor)
@connection(key: "UserList_users") {
edges {
node { id name }
}
pageInfo {
hasNextPage
endCursor
}
}
}
Why it matters: pageInfo provides the metadata (hasNextPage, endCursor) that usePaginationFragment needs to track pagination state.
Key Takeaways π―
π Quick Reference Card
| Concept | Key Point |
|---|---|
| Hook Purpose | Manages cursor-based pagination for large datasets |
| Required Directives | @refetchable + @connection |
| Forward Pagination | loadNext(count) + check hasNext |
| Backward Pagination | loadPrevious(count) + check hasPrevious |
| Reset Pagination | refetch(variables) with new filters |
| Data Structure | edges[].node pattern with pageInfo |
| Connection Key | Must be unique per connection in your app |
| Loading States | isLoadingNext / isLoadingPrevious |
| Cursor Management | AutomaticβRelay handles cursor tracking |
| Best Practice | Always disable buttons during loading |
Remember This Pattern π§
The Pagination Checklist:
- β
Fragment has
@refetchabledirective - β
Connection has
@connection(key: "UniqueKey") - β
Query includes
pageInfo { hasNextPage, endCursor } - β
Check
hasNextbefore callingloadNext() - β
Disable UI during
isLoadingNext - β
Use
refetch()when filters change - β
Access data via
data.connection.edges[].node
When to Use usePaginationFragment
Great for:
- π Infinite scroll lists (social feeds, search results)
- π Large datasets that can't load all at once
- π Lists that need filtering/sorting with pagination
- π± Mobile apps where bandwidth is limited
- ποΈ Timeline/chronological data navigation
Not needed for:
- π Small lists (< 100 items) that fit in memory
- π’ Fixed-size datasets that don't grow
- π Data better suited for traditional page numbers
- π― Single-item detail views
π Further Study
Deepen your understanding with these resources:
- Relay Pagination Documentation - Official guide with advanced patterns
- GraphQL Cursor Connections Specification - The standard that defines connection structure
- Relay Examples Repository - Real-world code samples including complex pagination scenarios
Mastering usePaginationFragment unlocks efficient data loading patterns that scale from hundreds to millions of items. Practice implementing these patterns in your applications, and you'll build experiences that feel fast and responsive regardless of dataset size! π