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

Lists, Scale, and Reality

Handle large lists properly using Relay's connection specification and pagination patterns

Lists, Scale, and Reality

Master Relay pagination with free flashcards and proven techniques for handling real-world data challenges. This lesson covers cursor-based pagination, connection patterns, and performance optimizationβ€”essential concepts for building scalable GraphQL applications with Relay.

Welcome πŸš€

When you're building modern web applications, displaying data isn't just about showing a few items on screen. What happens when you need to show 10,000 products? A million social media posts? Real-world applications deal with massive datasets that can't simply be loaded all at once. This is where Relay's approach to lists becomes absolutely critical.

In this lesson, we'll explore how Relay solves one of the most common challenges in web development: efficiently displaying large collections of data. You'll learn why Facebook invented the Connection pattern, how cursor-based pagination works under the hood, and practical strategies for keeping your applications fast even as your data grows.

Core Concepts πŸ“š

The Problem with Naive List Rendering

Imagine you're building an e-commerce site with 50,000 products. If you tried to fetch and render all products at once, you'd face several catastrophic problems:

❌ NAIVE APPROACH: Fetch Everything

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Request: getAllProducts()          β”‚
β”‚  Response: [50,000 products...]     β”‚
β”‚  Size: ~50 MB of JSON               β”‚
β”‚  Time: 30+ seconds                  β”‚
β”‚  Browser: πŸ’₯ CRASH                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“Š User Experience:
πŸ• Long wait β†’ 😴 User leaves
πŸ’Ύ Memory spike β†’ πŸ”₯ Browser freezes
πŸ“± Mobile device β†’ ☠️ App killed

The fundamental issue: Networks have limited bandwidth, browsers have limited memory, and users have limited patience.

The Solution: Pagination

Pagination is the practice of breaking large datasets into smaller, manageable chunks. Instead of loading 50,000 products, you load 20 at a time:

PAGINATION APPROACHES

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  OFFSET-BASED (Traditional)              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚  β”‚Page 1β”‚Page 2β”‚Page 3β”‚Page 4β”‚          β”‚
β”‚  β”‚ 1-20 β”‚21-40 β”‚41-60 β”‚61-80 β”‚          β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚  API: ?page=2&limit=20                   β”‚
β”‚  SQL: LIMIT 20 OFFSET 20                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   ⚠️
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  CURSOR-BASED (Relay's Approach)         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚Item Aβ”‚β†’β”‚Item Bβ”‚β†’β”‚Item Cβ”‚β†’β”‚Item Dβ”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚  Cursors: ["XyZ1","AbC2","DeF3"...]     β”‚
β”‚  API: after="AbC2", first=20             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why Cursor-Based Pagination Wins

Offset-based pagination seems simple, but it has critical flaws:

ProblemScenarioResult
πŸ”„ Data ChangesUser on page 2, new item added to page 1Item from page 2 appears twice on page 3
πŸ—‘οΈ DeletionsItem deleted from page 1 while viewing page 2Skip an item entirely
⚑ PerformanceRequest page 1000 with OFFSET 20000Database must scan 20,000 rows to skip them
πŸ”€ Real-time SyncMultiple users adding/removing itemsInconsistent pagination state

Cursor-based pagination solves these problems by using opaque tokens (cursors) that point to specific positions in the dataset:

πŸ’‘ Key Insight: A cursor is like a bookmark in a book. No matter how many pages are added or removed before your bookmark, it still points to the same content.

The Connection Pattern πŸ”—

Relay standardizes list data using the Connection pattern, a GraphQL convention that provides rich pagination metadata:

RELAY CONNECTION STRUCTURE

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  CONNECTION                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ edges: [                              β”‚ β”‚
β”‚  β”‚   {                                   β”‚ β”‚
β”‚  β”‚     node: { id, name, ... }  ◄─ Actual data
β”‚  β”‚     cursor: "Y3Vyc29yOjE="   ◄─ Position marker
β”‚  β”‚   },                                  β”‚ β”‚
β”‚  β”‚   { node: {...}, cursor: "..." }     β”‚ β”‚
β”‚  β”‚ ]                                     β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ pageInfo: {                           β”‚ β”‚
β”‚  β”‚   hasNextPage: true     ◄─ More items?
β”‚  β”‚   hasPreviousPage: false              β”‚ β”‚
β”‚  β”‚   startCursor: "Y3Vyc29yOjE="         β”‚ β”‚
β”‚  β”‚   endCursor: "Y3Vyc29yOjIw"           β”‚ β”‚
β”‚  β”‚ }                                     β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Breaking it down:

  • edges: Array of items, each containing:
    • node: The actual data object (product, user, post, etc.)
    • cursor: Opaque string identifying this item's position
  • pageInfo: Metadata for pagination UI:
    • hasNextPage: Boolean indicating if more items exist after this page
    • hasPreviousPage: Boolean indicating if items exist before this page
    • startCursor: Cursor of the first item in this page
    • endCursor: Cursor of the last item in this page

Cursors: The Magic Tokens 🎫

What exactly is a cursor? It's an opaque string that encodes positional information:

## Common cursor encoding (base64)
"Y3Vyc29yOjE="  // Decodes to: "cursor:1"
"Y3Vyc29yOjIw" // Decodes to: "cursor:20"

πŸ’‘ Important: Cursors are opaque to clientsβ€”you should never parse or construct them manually. The server generates them, and clients use them as-is.

Cursors can encode various strategies:

  • ID-based: cursor:product-123 (works with any unique identifier)
  • Timestamp-based: cursor:2024-01-15T10:30:00Z (for chronological data)
  • Composite: cursor:2024-01-15:product-123 (combines multiple factors)
  • Encrypted: Encrypted positional data for security

Pagination Arguments πŸ“

Relay uses four standard arguments for pagination:

ArgumentTypePurposeExample
firstIntTake N items forwardfirst: 10
afterStringStart after this cursorafter: "Y3Vyc29yOjE="
lastIntTake N items backwardlast: 10
beforeStringStart before this cursorbefore: "Y3Vyc29yOjIw"

Forward pagination (most common):

query {
  products(first: 20, after: "cursor123") {
    edges {
      node { name }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Backward pagination (for "previous page" buttons):

query {
  products(last: 20, before: "cursor123") {
    edges {
      node { name }
      cursor
    }
    pageInfo {
      hasPreviousPage
      startCursor
    }
  }
}

Scale Considerations: Performance at Reality 🏎️

When your application grows from hundreds to millions of records, pagination strategy becomes critical:

Database Query Performance
QUERY PERFORMANCE COMPARISON

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ OFFSET-BASED (Gets worse with scale)  β”‚
β”‚                                        β”‚
β”‚ SELECT * FROM products                 β”‚
β”‚ LIMIT 20 OFFSET 1000000;               β”‚
β”‚                                        β”‚
β”‚ Database must:                         β”‚
β”‚ 1. Scan 1,000,000 rows ⏱️ 8.2s        β”‚
β”‚ 2. Skip all of them    ⏱️             β”‚
β”‚ 3. Return 20 rows      ⏱️             β”‚
β”‚                                        β”‚
β”‚ Total: ~8-10 seconds 🐌                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CURSOR-BASED (Consistent performance)  β”‚
β”‚                                        β”‚
β”‚ SELECT * FROM products                 β”‚
β”‚ WHERE id > 'cursor-value'              β”‚
β”‚ ORDER BY id                            β”‚
β”‚ LIMIT 20;                              β”‚
β”‚                                        β”‚
β”‚ Database uses:                         β”‚
β”‚ 1. Index seek directly ⏱️ 0.02s       β”‚
β”‚ 2. Return 20 rows      ⏱️             β”‚
β”‚                                        β”‚
β”‚ Total: ~20-50ms ⚑                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why cursor-based is faster: It uses indexed seeks instead of table scans. The database jumps directly to the cursor position rather than counting from the beginning.

πŸ’‘ Pro Tip: For cursor-based pagination to be fast, ensure your cursor field is indexed!

-- Make sure your cursor column has an index
CREATE INDEX idx_products_id ON products(id);
CREATE INDEX idx_posts_created_at ON posts(created_at);
Memory Management

Relay's approach also helps with client-side memory:

MEMORY USAGE: Infinite Scroll Example

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ WITHOUT RELAY                          β”‚
β”‚                                        β”‚
β”‚ User scrolls β†’ Append to array         β”‚
β”‚ [item1, item2, ... item10000] πŸ’Ύ 500MB β”‚
β”‚                                        β”‚
β”‚ Problem: Array grows forever           β”‚
β”‚ Result: Browser crashes πŸ’₯             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ WITH RELAY STORE                       β”‚
β”‚                                        β”‚
β”‚ User scrolls β†’ Store manages cache     β”‚
β”‚ Cache: 200 items in memory πŸ’Ύ 10MB     β”‚
β”‚                                        β”‚
β”‚ Strategy: Keep visible + nearby items  β”‚
β”‚ Result: Smooth scrolling βœ…            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Relay's store can:

  • Cache intelligently: Keep frequently accessed items
  • Evict strategically: Remove items far from viewport
  • Deduplicate: Same item in multiple queries stored once

Implementing Load More with Relay πŸ”„

Here's how you implement "Load More" functionality with Relay's hooks:

import { graphql, usePaginationFragment } from 'react-relay';

function ProductList({ queryRef }) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext,
  } = usePaginationFragment(
    graphql`
      fragment ProductList_query on Query
      @refetchable(queryName: "ProductListPaginationQuery") {
        products(first: $count, after: $cursor)
        @connection(key: "ProductList_products") {
          edges {
            node {
              id
              name
              price
            }
          }
        }
      }
    `,
    queryRef
  );

  return (
    <div>
      {data.products.edges.map(({ node }) => (
        <ProductCard key={node.id} product={node} />
      ))}
      
      {hasNext && (
        <button
          onClick={() => loadNext(20)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

What's happening here:

  1. usePaginationFragment: Relay hook that provides pagination capabilities
  2. @refetchable: Directive telling Relay this fragment can be refetched with new arguments
  3. @connection: Directive that tells Relay to manage this as a connection (append new items to existing list)
  4. loadNext(20): Function to fetch the next 20 items
  5. hasNext: Boolean from pageInfo.hasNextPage
  6. isLoadingNext: Loading state for the pagination request

The @connection Directive πŸ”Œ

The @connection directive is Relay's secret weapon for managing paginated lists:

fragment Feed_query on Query {
  posts(first: $count, after: $cursor)
  @connection(key: "Feed_posts") {
    edges {
      node {
        id
        content
      }
    }
  }
}

What @connection does:

  1. Assigns a stable key: "Feed_posts" uniquely identifies this connection in Relay's store
  2. Merges paginated results: When you load more, new items are appended to existing ones
  3. Handles cache updates: Insertions, deletions, and updates work correctly
  4. Enables optimistic updates: You can add items before server confirmation

πŸ’‘ Connection keys must be unique per component. If two components use the same connection key, they'll share the same cached data (which might be what you want, or might cause bugs!).

Examples with Explanations 🎯

Example 1: Building an Infinite Scroll Feed

Scenario: You're building a social media feed that loads more posts as the user scrolls.

import { graphql, usePaginationFragment } from 'react-relay';
import { useEffect, useRef } from 'react';

function InfiniteFeed({ queryRef }) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext,
  } = usePaginationFragment(
    graphql`
      fragment InfiniteFeed_query on Query
      @refetchable(queryName: "InfiniteFeedPaginationQuery") {
        posts(first: $count, after: $cursor)
        @connection(key: "InfiniteFeed_posts") {
          edges {
            node {
              id
              content
              author {
                name
                avatar
              }
              createdAt
            }
          }
        }
      }
    `,
    queryRef
  );

  // Ref to the loading sentinel element
  const sentinelRef = useRef(null);

  useEffect(() => {
    // Set up Intersection Observer for automatic loading
    const observer = new IntersectionObserver(
      (entries) => {
        // When sentinel becomes visible and we have more items
        if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
          loadNext(10); // Load 10 more posts
        }
      },
      { threshold: 0.5 } // Trigger when 50% visible
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [hasNext, isLoadingNext, loadNext]);

  return (
    <div className="feed">
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      
      {/* Invisible sentinel element */}
      {hasNext && (
        <div ref={sentinelRef} style={{ height: '20px' }}>
          {isLoadingNext && <Spinner />}
        </div>
      )}
      
      {!hasNext && <p>You've reached the end! πŸŽ‰</p>}
    </div>
  );
}

Key techniques:

  • Intersection Observer API: Detects when the sentinel element enters the viewport
  • Automatic loading: Fetches more items without requiring a button click
  • Loading states: Shows spinner while fetching
  • End detection: Displays message when no more items exist

🧠 Memory tip: Think of the sentinel as a tripwireβ€”when the user scrolls far enough to "trip" it, you load more content.

Example 2: Bidirectional Pagination with Page Numbers

Scenario: Building a traditional paginated table with "Previous" and "Next" buttons.

import { graphql, usePaginationFragment } from 'react-relay';
import { useState } from 'react';

function PaginatedTable({ queryRef }) {
  const [currentPage, setCurrentPage] = useState(1);
  const PAGE_SIZE = 25;

  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
  } = usePaginationFragment(
    graphql`
      fragment PaginatedTable_query on Query
      @refetchable(queryName: "PaginatedTableQuery") {
        users(first: $count, after: $cursor)
        @connection(key: "PaginatedTable_users") {
          edges {
            node {
              id
              name
              email
              role
            }
          }
          pageInfo {
            startCursor
            endCursor
          }
        }
      }
    `,
    queryRef
  );

  const handleNext = () => {
    if (hasNext) {
      loadNext(PAGE_SIZE);
      setCurrentPage(prev => prev + 1);
    }
  };

  const handlePrevious = () => {
    if (hasPrevious) {
      loadPrevious(PAGE_SIZE);
      setCurrentPage(prev => prev - 1);
    }
  };

  return (
    <div>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Role</th>
          </tr>
        </thead>
        <tbody>
          {data.users.edges.map(({ node }) => (
            <tr key={node.id}>
              <td>{node.name}</td>
              <td>{node.email}</td>
              <td>{node.role}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <div className="pagination">
        <button
          onClick={handlePrevious}
          disabled={!hasPrevious || isLoadingPrevious}
        >
          ← Previous
        </button>
        
        <span>Page {currentPage}</span>
        
        <button
          onClick={handleNext}
          disabled={!hasNext || isLoadingNext}
        >
          Next β†’
        </button>
      </div>
    </div>
  );
}

Important considerations:

  • loadPrevious: Works with last and before arguments under the hood
  • Button states: Disabled when loading or no more pages
  • Page tracking: Local state tracks current page number for UI

⚠️ Common mistake: Don't try to calculate total pages with cursor paginationβ€”you can't know the total without counting all items (which defeats the purpose of pagination).

Example 3: Search with Pagination

Scenario: Search results that update as the user types, with pagination for large result sets.

import { graphql, usePaginationFragment } from 'react-relay';
import { useState, useTransition } from 'react';
import { useQueryLoader } from 'react-relay';

function SearchResults({ queryRef }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [isPending, startTransition] = useTransition();

  const {
    data,
    loadNext,
    hasNext,
    refetch,
  } = usePaginationFragment(
    graphql`
      fragment SearchResults_query on Query
      @refetchable(queryName: "SearchResultsPaginationQuery")
      @argumentDefinitions(
        searchTerm: { type: "String", defaultValue: "" }
      ) {
        searchProducts(query: $searchTerm, first: $count, after: $cursor)
        @connection(key: "SearchResults_searchProducts") {
          edges {
            node {
              id
              name
              description
              price
            }
          }
        }
      }
    `,
    queryRef
  );

  const handleSearch = (newTerm) => {
    setSearchTerm(newTerm);
    
    // Use transition to keep UI responsive during refetch
    startTransition(() => {
      refetch(
        { searchTerm: newTerm, count: 20 },
        { fetchPolicy: 'store-and-network' }
      );
    });
  };

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search products..."
      />
      
      {isPending && <div>Searching...</div>}
      
      <div className="results">
        {data.searchProducts.edges.length === 0 ? (
          <p>No results found for "{searchTerm}"</p>
        ) : (
          data.searchProducts.edges.map(({ node }) => (
            <ProductCard key={node.id} product={node} />
          ))
        )}
      </div>
      
      {hasNext && (
        <button onClick={() => loadNext(20)}>
          Load More Results
        </button>
      )}
    </div>
  );
}

Advanced patterns here:

  • refetch: Starts a new query with different variables (new search term)
  • useTransition: React 18 hook that keeps UI responsive during updates
  • @argumentDefinitions: Declares variables the fragment depends on
  • fetchPolicy: 'store-and-network': Shows cached results immediately, then updates with fresh data

πŸ’‘ Performance tip: Add debouncing to avoid refetching on every keystroke:

import { useDebouncedCallback } from 'use-debounce';

const debouncedSearch = useDebouncedCallback(
  (term) => {
    startTransition(() => {
      refetch({ searchTerm: term, count: 20 });
    });
  },
  300 // Wait 300ms after typing stops
);

Example 4: Handling Real-Time Updates in Paginated Lists

Scenario: A chat application where new messages arrive while viewing paginated history.

import { graphql, usePaginationFragment, useSubscription } from 'react-relay';
import { useEffect } from 'react';

function ChatHistory({ conversationId, queryRef }) {
  const {
    data,
    loadPrevious, // Load older messages
    hasPrevious,
  } = usePaginationFragment(
    graphql`
      fragment ChatHistory_conversation on Conversation
      @refetchable(queryName: "ChatHistoryPaginationQuery") {
        messages(last: $count, before: $cursor)
        @connection(key: "ChatHistory_messages") {
          edges {
            node {
              id
              content
              sender {
                name
              }
              timestamp
            }
          }
        }
      }
    `,
    queryRef
  );

  // Subscribe to new messages
  useSubscription(
    React.useMemo(
      () => ({
        subscription: graphql`
          subscription ChatHistoryNewMessageSubscription($conversationId: ID!) {
            newMessage(conversationId: $conversationId) {
              message {
                id
                content
                sender {
                  name
                }
                timestamp
              }
            }
          }
        `,
        variables: { conversationId },
        onNext: (response) => {
          // New message automatically added to connection by Relay
          console.log('New message received:', response.newMessage);
        },
      }),
      [conversationId]
    )
  );

  return (
    <div className="chat-container">
      {hasPrevious && (
        <button onClick={() => loadPrevious(20)}>
          Load Older Messages
        </button>
      )}
      
      <div className="messages">
        {data.messages.edges.map(({ node }) => (
          <div key={node.id} className="message">
            <strong>{node.sender.name}:</strong> {node.content}
            <span className="timestamp">{node.timestamp}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

Real-time integration:

  • Subscriptions: WebSocket-based updates for new messages
  • Automatic merging: Relay adds new items to the connection automatically
  • Backward pagination: Uses last and before for chat history (newest first)
  • Connection stability: Cursors remain valid even as new messages arrive

🌍 Real-world insight: Facebook Messenger uses this exact patternβ€”you can scroll up to load history while new messages appear at the bottom.

Common Mistakes ⚠️

Mistake 1: Not Using Connection Keys Properly

❌ Wrong:

fragment UserList_query on Query {
  users(first: $count, after: $cursor)
  @connection(key: "users") {  # Too generic!
    edges { node { id name } }
  }
}

fragment AdminList_query on Query {
  users(first: $count, after: $cursor)
  @connection(key: "users") {  # Same key!
    edges { node { id name } }
  }
}

Problem: Both components share the same connection cache, causing bugs when they should show different data.

βœ… Correct:

fragment UserList_query on Query {
  users(first: $count, after: $cursor)
  @connection(key: "UserList_users") {  # Component-specific
    edges { node { id name } }
  }
}

fragment AdminList_query on Query {
  users(first: $count, after: $cursor, role: ADMIN)
  @connection(key: "AdminList_users") {  # Different key
    edges { node { id name } }
  }
}

Mistake 2: Forgetting to Handle Empty States

❌ Wrong:

return (
  <div>
    {data.products.edges.map(({ node }) => (
      <ProductCard key={node.id} product={node} />
    ))}
    {/* What if edges is empty? */}
  </div>
);

Problem: Users see a blank screen with no explanation.

βœ… Correct:

return (
  <div>
    {data.products.edges.length === 0 ? (
      <div className="empty-state">
        <p>No products found</p>
        <button onClick={onCreateProduct}>Add Your First Product</button>
      </div>
    ) : (
      data.products.edges.map(({ node }) => (
        <ProductCard key={node.id} product={node} />
      ))
    )}
  </div>
);

Mistake 3: Loading Too Many Items at Once

❌ Wrong:

// Load 1000 items at once
loadNext(1000);

Problem:

  • Huge network payload
  • Long wait time
  • Memory spike
  • Poor user experience

βœ… Correct:

// Load reasonable chunks
const PAGE_SIZE = 20; // Or 50 for large screens
loadNext(PAGE_SIZE);

πŸ’‘ Guidelines for page size:

  • Mobile: 10-20 items
  • Desktop: 20-50 items
  • Large screens: 50-100 items
  • Never exceed: 100 items per page

Mistake 4: Not Checking Loading States

❌ Wrong:

<button onClick={() => loadNext(20)}>
  Load More
</button>

Problem: Users can spam-click, causing multiple simultaneous requests.

βœ… Correct:

<button 
  onClick={() => loadNext(20)}
  disabled={isLoadingNext || !hasNext}
>
  {isLoadingNext ? 'Loading...' : 'Load More'}
</button>

Mistake 5: Ignoring Cursor Expiration

❌ Wrong:

// Store cursor in localStorage for days
localStorage.setItem('lastCursor', endCursor);

// Later, use stale cursor
const cursor = localStorage.getItem('lastCursor');
refetch({ after: cursor }); // Might be invalid!

Problem: Cursors can expire or become invalid if the underlying data changes significantly.

βœ… Correct:

// Handle cursor errors gracefully
const [error, setError] = useState(null);

try {
  loadNext(20);
} catch (err) {
  if (err.message.includes('Invalid cursor')) {
    // Restart from beginning
    refetch({ after: null, count: 20 });
  }
}

Mistake 6: Not Optimizing Database Queries

❌ Wrong server implementation:

// Resolver without proper indexing
Query: {
  products: async (_, { first, after }) => {
    // Inefficient: Full table scan then filter
    const allProducts = await db.products.findAll();
    const startIndex = after ? findIndex(after) : 0;
    return allProducts.slice(startIndex, startIndex + first);
  }
}

βœ… Correct server implementation:

Query: {
  products: async (_, { first, after }) => {
    // Efficient: Use indexed WHERE clause
    const query = db.products
      .where('id', '>', decodeCursor(after))
      .orderBy('id', 'asc')
      .limit(first);
    
    return query.execute();
  }
}

Server-side best practices:

  • βœ… Index cursor fields (id, created_at, etc.)
  • βœ… Use compound indexes for complex sorts
  • βœ… Set reasonable max page size (prevent first: 1000000)
  • βœ… Add query timeouts
  • βœ… Monitor slow queries

Key Takeaways 🎯

πŸ“‹ Quick Reference: Relay Pagination Essentials

Concept Key Points
Why Cursor Pagination? β€’ Handles data changes gracefully
β€’ Consistent performance at scale
β€’ No duplicate or skipped items
β€’ Works with real-time updates
Connection Structure β€’ edges: Array of {node, cursor}
β€’ pageInfo: {hasNextPage, hasPreviousPage, startCursor, endCursor}
β€’ node: Your actual data
Pagination Arguments β€’ first + after: Forward pagination
β€’ last + before: Backward pagination
β€’ Never expose cursor internals to clients
Essential Directives β€’ @connection(key: "unique"): Manages list state
β€’ @refetchable: Enables refetching with new variables
β€’ @argumentDefinitions: Declares fragment variables
Key Hook Functions β€’ loadNext(n): Fetch next n items
β€’ loadPrevious(n): Fetch previous n items
β€’ refetch(vars): Start fresh query
β€’ hasNext / hasPrevious: Check availability
Performance Tips β€’ Keep page sizes reasonable (20-50 items)
β€’ Index cursor fields in database
β€’ Use Intersection Observer for infinite scroll
β€’ Debounce search queries
β€’ Handle loading and empty states

Remember These Golden Rules 🌟

  1. Cursors are opaque: Never parse or construct them manually
  2. Connection keys must be unique: One key per component/use case
  3. Always check hasNext/hasPrevious: Don't try to load beyond boundaries
  4. Handle all states: Loading, empty, error, and success
  5. Optimize from the start: Index database fields, reasonable page sizes
  6. Think about scale: What works for 100 items should work for 1,000,000
  7. Test edge cases: Empty lists, single item, network failures

Further Study πŸ“š

Deepen your understanding with these resources:

  1. Relay Pagination Documentation: https://relay.dev/docs/guided-tour/list-data/pagination/ - Official guide with detailed API reference

  2. GraphQL Cursor Connections Specification: https://relay.dev/graphql/connections.htm - The standard specification that defines connection patterns

  3. React Relay Hooks API: https://relay.dev/docs/api-reference/hooks/use-pagination-fragment/ - Complete reference for usePaginationFragment and related hooks


Congratulations! πŸŽ‰ You now understand how to build scalable, performant list interfaces with Relay. Whether you're building infinite scroll feeds, traditional paginated tables, or real-time chat histories, you have the tools to handle millions of items with grace. Keep practicing these patterns, and you'll build applications that feel fast no matter how large your data grows!