You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

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:

  1. Automatic cursor management - Keeps track of where you are in the dataset
  2. Built-in loading states - Tells you when more data is being fetched
  3. Bi-directional pagination - Load data forward ("load more") or backward ("load previous")

The hook returns an object with these key properties:

PropertyTypePurpose
dataObjectThe actual paginated data
loadNextFunctionFetch the next page of results
loadPreviousFunctionFetch the previous page of results
hasNextBooleanAre there more items after current page?
hasPreviousBooleanAre there items before current page?
isLoadingNextBooleanCurrently fetching next page?
isLoadingPreviousBooleanCurrently fetching previous page?
refetchFunctionRestart 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:

  1. Guard your loadNext calls: Always check hasNext and !isLoadingNext before calling loadNext() to prevent unnecessary requests
  2. Pass the count: loadNext(10) fetches 10 more items. This can be different from your initial page size
  3. Access data through edges: The data is at data.users.edges[].node, not directly at data.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:

  1. Clears the existing connection
  2. Fetches from the beginning with new filters
  3. 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:

  1. βœ… Fragment has @refetchable directive
  2. βœ… Connection has @connection(key: "UniqueKey")
  3. βœ… Query includes pageInfo { hasNextPage, endCursor }
  4. βœ… Check hasNext before calling loadNext()
  5. βœ… Disable UI during isLoadingNext
  6. βœ… Use refetch() when filters change
  7. βœ… 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:

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! πŸš€