Pagination Patterns Guide

Learn how to implement efficient pagination patterns for large datasets using GraphQL cursor-based pagination, infinite scrolling, and optimized data fetching strategies.

Prerequisites

  • Understanding of GraphQL basics
  • Familiarity with React or your preferred frontend framework
  • Basic knowledge of database concepts
  • Unified Commerce API access

Table of Contents

  1. Pagination Overview
  2. Cursor-Based Pagination
  3. Offset Pagination
  4. Infinite Scrolling
  5. Load More Pattern
  6. Hybrid Approaches
  7. Performance Optimization
  8. Search & Filter Integration
  9. Real-World Examples
  10. Troubleshooting

Pagination Overview

Why Cursor-Based Pagination?

Unified Commerce uses cursor-based pagination (Relay specification) because it:

  • Handles real-time data - No issues with items being added/removed
  • Scales efficiently - O(1) database queries regardless of page depth
  • Provides stable results - Cursors remain valid even as data changes
  • Enables bidirectional navigation - Move forward and backward through results

Pagination Schema

# Connection type following Relay specification
type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ProductEdge {
  cursor: String!
  node: Product!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# Query with pagination arguments
type Query {
  products(
    first: Int
    after: String
    last: Int
    before: String
    filters: ProductFilterInput
    sortBy: ProductSortField
    sortOrder: SortOrder
  ): ProductConnection!
}

Cursor-Based Pagination

Basic Implementation

// hooks/use-products.ts
import { gql, useQuery } from '@apollo/client';

const GET_PRODUCTS = gql`
  query GetProducts(
    $first: Int!
    $after: String
    $filters: ProductFilterInput
    $sortBy: ProductSortField
    $sortOrder: SortOrder
  ) {
    products(
      first: $first
      after: $after
      filters: $filters
      sortBy: $sortBy
      sortOrder: $sortOrder
    ) {
      edges {
        cursor
        node {
          id
          name
          slug
          price
          currency
          images(first: 1) {
            url
            alt
          }
          inventory {
            available
          }
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      totalCount
    }
  }
`;

export function useProducts(options: ProductQueryOptions = {}) {
  const {
    pageSize = 20,
    filters = {},
    sortBy = 'CREATED_AT',
    sortOrder = 'DESC',
  } = options;

  const { data, loading, error, fetchMore, refetch } = useQuery(GET_PRODUCTS, {
    variables: {
      first: pageSize,
      after: null,
      filters,
      sortBy,
      sortOrder,
    },
    notifyOnNetworkStatusChange: true,
  });

  const loadMore = async () => {
    if (!data?.products?.pageInfo?.hasNextPage) {
      return;
    }

    await fetchMore({
      variables: {
        after: data.products.pageInfo.endCursor,
      },
      updateQuery: (previousResult, { fetchMoreResult }) => {
        if (!fetchMoreResult) return previousResult;

        return {
          products: {
            ...fetchMoreResult.products,
            edges: [
              ...previousResult.products.edges,
              ...fetchMoreResult.products.edges,
            ],
            pageInfo: fetchMoreResult.products.pageInfo,
          },
        };
      },
    });
  };

  return {
    products: data?.products?.edges?.map(edge => edge.node) || [],
    pageInfo: data?.products?.pageInfo,
    totalCount: data?.products?.totalCount || 0,
    loading,
    error,
    loadMore,
    hasMore: data?.products?.pageInfo?.hasNextPage || false,
    refetch,
  };
}

Bidirectional Navigation

// components/paginated-list.tsx
import { useState } from 'react';
import { gql, useQuery } from '@apollo/client';

interface PaginationState {
  first?: number;
  after?: string;
  last?: number;
  before?: string;
}

export function PaginatedProductList() {
  const [paginationState, setPaginationState] = useState<PaginationState>({
    first: 20,
  });

  const { data, loading, error } = useQuery(GET_PRODUCTS, {
    variables: paginationState,
  });

  const goToNextPage = () => {
    setPaginationState({
      first: 20,
      after: data?.products?.pageInfo?.endCursor,
    });
  };

  const goToPreviousPage = () => {
    setPaginationState({
      last: 20,
      before: data?.products?.pageInfo?.startCursor,
    });
  };

  const goToFirstPage = () => {
    setPaginationState({
      first: 20,
    });
  };

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;

  const products = data?.products?.edges?.map(e => e.node) || [];
  const { hasNextPage, hasPreviousPage } = data?.products?.pageInfo || {};

  return (
    <div>
      <ProductGrid products={products} />

      <div className="pagination-controls">
        <button
          onClick={goToFirstPage}
          disabled={!hasPreviousPage}
          className="btn btn-secondary"
        >
          First
        </button>

        <button
          onClick={goToPreviousPage}
          disabled={!hasPreviousPage}
          className="btn btn-secondary"
        >
          Previous
        </button>

        <span className="page-info">
          Showing {products.length} of {data?.products?.totalCount} products
        </span>

        <button
          onClick={goToNextPage}
          disabled={!hasNextPage}
          className="btn btn-secondary"
        >
          Next
        </button>
      </div>
    </div>
  );
}

Cursor Management

// utils/cursor-manager.ts
export class CursorManager {
  private history: string[] = [];
  private currentIndex: number = -1;

  push(cursor: string) {
    // Remove any forward history when navigating to a new page
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.history.push(cursor);
    this.currentIndex++;
  }

  back(): string | null {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return this.history[this.currentIndex];
    }
    return null;
  }

  forward(): string | null {
    if (this.currentIndex < this.history.length - 1) {
      this.currentIndex++;
      return this.history[this.currentIndex];
    }
    return null;
  }

  reset() {
    this.history = [];
    this.currentIndex = -1;
  }

  get canGoBack(): boolean {
    return this.currentIndex > 0;
  }

  get canGoForward(): boolean {
    return this.currentIndex < this.history.length - 1;
  }

  get current(): string | null {
    return this.history[this.currentIndex] || null;
  }
}

// Usage in component
export function NavigableProductList() {
  const [cursorManager] = useState(() => new CursorManager());
  const [currentCursor, setCurrentCursor] = useState<string | null>(null);

  const { data, loading, refetch } = useQuery(GET_PRODUCTS, {
    variables: {
      first: 20,
      after: currentCursor,
    },
  });

  const navigate = (direction: 'forward' | 'back' | 'next') => {
    let newCursor: string | null = null;

    switch (direction) {
      case 'next':
        newCursor = data?.products?.pageInfo?.endCursor || null;
        if (newCursor) {
          cursorManager.push(newCursor);
        }
        break;
      case 'back':
        newCursor = cursorManager.back();
        break;
      case 'forward':
        newCursor = cursorManager.forward();
        break;
    }

    setCurrentCursor(newCursor);
  };

  return (
    // Component JSX
  );
}

Offset Pagination

While Unified Commerce primarily uses cursor-based pagination, offset pagination can be implemented for specific use cases:

// Offset pagination wrapper
export function useOffsetPagination(query: DocumentNode, options: Options = {}) {
  const [page, setPage] = useState(1);
  const pageSize = options.pageSize || 20;

  const { data, loading, error } = useQuery(query, {
    variables: {
      offset: (page - 1) * pageSize,
      limit: pageSize,
      ...options.variables,
    },
  });

  const totalPages = Math.ceil((data?.totalCount || 0) / pageSize);

  return {
    data: data?.items || [],
    loading,
    error,
    page,
    totalPages,
    hasNextPage: page < totalPages,
    hasPreviousPage: page > 1,
    goToPage: setPage,
    nextPage: () => setPage(p => Math.min(p + 1, totalPages)),
    previousPage: () => setPage(p => Math.max(p - 1, 1)),
  };
}

// Convert cursor to offset (approximation)
export function estimateOffsetFromCursor(
  cursor: string,
  pageSize: number
): number {
  // Decode cursor (assuming base64 encoded index)
  try {
    const decoded = atob(cursor);
    const index = parseInt(decoded.split(':')[1], 10);
    return Math.floor(index / pageSize) + 1;
  } catch {
    return 1;
  }
}

Infinite Scrolling

React Implementation

// hooks/use-infinite-scroll.ts
import { useEffect, useRef, useCallback } from 'react';
import { useInView } from 'react-intersection-observer';

export function useInfiniteScroll(
  loadMore: () => Promise<void>,
  hasMore: boolean,
  loading: boolean
) {
  const { ref, inView } = useInView({
    threshold: 0.1,
    rootMargin: '100px',
  });

  useEffect(() => {
    if (inView && hasMore && !loading) {
      loadMore();
    }
  }, [inView, hasMore, loading, loadMore]);

  return { ref };
}

// Component using infinite scroll
export function InfiniteProductList() {
  const { products, loading, hasMore, loadMore } = useProducts();
  const { ref } = useInfiniteScroll(loadMore, hasMore, loading);

  return (
    <div className="infinite-scroll-container">
      <div className="product-grid">
        {products.map((product, index) => (
          <ProductCard
            key={product.id}
            product={product}
            // Attach ref to trigger element (3 items before end)
            ref={index === products.length - 3 ? ref : undefined}
          />
        ))}
      </div>

      {loading && <LoadingSpinner />}

      {!hasMore && products.length > 0 && (
        <div className="end-message">
          You've reached the end! {products.length} products loaded.
        </div>
      )}
    </div>
  );
}

Virtual Scrolling for Large Lists

// components/virtual-product-list.tsx
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

export function VirtualProductList() {
  const { products, loadMore, hasMore } = useProducts({ pageSize: 50 });

  const loadMoreItems = useCallback(
    (startIndex: number, stopIndex: number) => {
      if (hasMore && stopIndex >= products.length - 10) {
        return loadMore();
      }
      return Promise.resolve();
    },
    [hasMore, loadMore, products.length]
  );

  const Row = ({ index, style }: { index: number; style: any }) => {
    const product = products[index];

    if (!product) {
      return (
        <div style={style}>
          <LoadingPlaceholder />
        </div>
      );
    }

    return (
      <div style={style}>
        <ProductRow product={product} />
      </div>
    );
  };

  return (
    <AutoSizer>
      {({ height, width }) => (
        <FixedSizeList
          height={height}
          width={width}
          itemCount={hasMore ? products.length + 10 : products.length}
          itemSize={120}
          onItemsRendered={({ visibleStopIndex }) => {
            if (visibleStopIndex >= products.length - 10) {
              loadMoreItems(0, visibleStopIndex);
            }
          }}
        >
          {Row}
        </FixedSizeList>
      )}
    </AutoSizer>
  );
}

Load More Pattern

Manual Load More Button

// components/load-more-list.tsx
export function LoadMoreProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const [initialLoading, setInitialLoading] = useState(true);

  const loadProducts = useCallback(async (after?: string | null) => {
    setLoading(true);

    try {
      const { data } = await client.query({
        query: GET_PRODUCTS,
        variables: {
          first: 20,
          after,
        },
      });

      const newProducts = data.products.edges.map(e => e.node);

      if (after) {
        setProducts(prev => [...prev, ...newProducts]);
      } else {
        setProducts(newProducts);
      }

      setCursor(data.products.pageInfo.endCursor);
      setHasMore(data.products.pageInfo.hasNextPage);
    } catch (error) {
      console.error('Failed to load products:', error);
    } finally {
      setLoading(false);
      setInitialLoading(false);
    }
  }, []);

  useEffect(() => {
    loadProducts();
  }, []);

  if (initialLoading) {
    return <LoadingGrid />;
  }

  return (
    <div className="load-more-container">
      <ProductGrid products={products} />

      {hasMore && (
        <div className="load-more-wrapper">
          <button
            onClick={() => loadProducts(cursor)}
            disabled={loading}
            className="load-more-btn"
          >
            {loading ? (
              <>
                <Spinner /> Loading...
              </>
            ) : (
              'Load More Products'
            )}
          </button>

          <p className="text-muted">
            Showing {products.length} products
          </p>
        </div>
      )}

      {!hasMore && products.length > 0 && (
        <p className="end-message">
          All {products.length} products loaded
        </p>
      )}
    </div>
  );
}

Progressive Loading

// components/progressive-list.tsx
export function ProgressiveProductList() {
  const [loadedPages, setLoadedPages] = useState(1);
  const [allProducts, setAllProducts] = useState<Product[]>([]);
  const itemsPerPage = 20;

  const {
    products,
    pageInfo,
    loading,
    fetchMore,
  } = useProducts({
    pageSize: itemsPerPage * 3, // Load 3 pages at once
  });

  // Show products progressively
  const visibleProducts = allProducts.slice(0, loadedPages * itemsPerPage);

  const showMore = () => {
    const totalLoaded = Math.floor(allProducts.length / itemsPerPage);

    if (loadedPages < totalLoaded) {
      // Show more already loaded products
      setLoadedPages(prev => prev + 1);
    } else if (pageInfo?.hasNextPage) {
      // Fetch more from server
      fetchMore({
        variables: {
          after: pageInfo.endCursor,
        },
      });
    }
  };

  useEffect(() => {
    setAllProducts(products);
  }, [products]);

  return (
    <div>
      <ProductGrid products={visibleProducts} />

      <div className="progress-info">
        <p>
          Showing {visibleProducts.length} of {allProducts.length} loaded
          {pageInfo?.hasNextPage && ' (more available)'}
        </p>

        <ProgressBar
          value={visibleProducts.length}
          max={allProducts.length}
        />

        {(loadedPages * itemsPerPage < allProducts.length || pageInfo?.hasNextPage) && (
          <button onClick={showMore} disabled={loading}>
            Show More
          </button>
        )}
      </div>
    </div>
  );
}

Hybrid Approaches

Pagination with Infinite Scroll

// components/hybrid-pagination.tsx
export function HybridPagination() {
  const [mode, setMode] = useState<'paginated' | 'infinite'>('paginated');
  const [currentPage, setCurrentPage] = useState(1);
  const pageSize = 24;

  const {
    products,
    pageInfo,
    totalCount,
    loading,
    loadMore,
    refetch,
  } = useProducts({ pageSize });

  const totalPages = Math.ceil(totalCount / pageSize);

  // Switch between modes
  const toggleMode = () => {
    setMode(prev => prev === 'paginated' ? 'infinite' : 'paginated');
  };

  if (mode === 'infinite') {
    return (
      <div>
        <button onClick={toggleMode}>Switch to Pages</button>
        <InfiniteProductList />
      </div>
    );
  }

  return (
    <div>
      <button onClick={toggleMode}>Switch to Infinite Scroll</button>

      <ProductGrid products={products} />

      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={(page) => {
          setCurrentPage(page);
          // Calculate cursor for page
          refetch({
            first: pageSize,
            after: calculateCursorForPage(page, pageSize),
          });
        }}
      />
    </div>
  );
}

Smart Pagination

// components/smart-pagination.tsx
export function SmartPagination() {
  const [strategy, setStrategy] = useState<'cursor' | 'numbered' | 'infinite'>('cursor');
  const deviceType = useDeviceType(); // mobile, tablet, desktop

  // Auto-select strategy based on context
  useEffect(() => {
    if (deviceType === 'mobile') {
      setStrategy('infinite');
    } else if (totalCount > 1000) {
      setStrategy('cursor'); // Better for large datasets
    } else {
      setStrategy('numbered'); // Better UX for small datasets
    }
  }, [deviceType, totalCount]);

  switch (strategy) {
    case 'infinite':
      return <InfiniteScrollProducts />;

    case 'numbered':
      return <NumberedPagination totalItems={totalCount} />;

    case 'cursor':
    default:
      return <CursorPagination />;
  }
}

Performance Optimization

Prefetching Next Page

// hooks/use-prefetch-pagination.ts
export function usePrefetchPagination() {
  const cache = useApolloClient();

  const prefetchNextPage = useCallback(
    async (cursor: string) => {
      await cache.query({
        query: GET_PRODUCTS,
        variables: {
          first: 20,
          after: cursor,
        },
      });
    },
    [cache]
  );

  const { data, loading, fetchMore } = useQuery(GET_PRODUCTS, {
    variables: { first: 20 },
    onCompleted: (data) => {
      // Prefetch next page when current page loads
      if (data?.products?.pageInfo?.endCursor) {
        prefetchNextPage(data.products.pageInfo.endCursor);
      }
    },
  });

  return {
    products: data?.products,
    loading,
    fetchMore,
    prefetchNextPage,
  };
}

Optimistic Pagination

// utils/optimistic-pagination.ts
export function optimisticPaginationUpdate(
  cache: ApolloCache<any>,
  queryName: string,
  newItem: any
) {
  const existing = cache.readQuery({
    query: GET_PRODUCTS,
    variables: { first: 20 },
  });

  if (existing) {
    cache.writeQuery({
      query: GET_PRODUCTS,
      variables: { first: 20 },
      data: {
        ...existing,
        [queryName]: {
          ...existing[queryName],
          edges: [
            {
              __typename: 'ProductEdge',
              cursor: generateTempCursor(),
              node: newItem,
            },
            ...existing[queryName].edges,
          ],
          totalCount: existing[queryName].totalCount + 1,
        },
      },
    });
  }
}

function generateTempCursor(): string {
  return btoa(`temp:${Date.now()}`);
}

Debounced Loading

// hooks/use-debounced-pagination.ts
export function useDebouncedPagination(delay: number = 300) {
  const [debouncedLoad] = useDebouncedCallback(
    (loadFn: () => void) => {
      loadFn();
    },
    delay
  );

  return {
    loadMore: useCallback((originalLoad: () => void) => {
      debouncedLoad(originalLoad);
    }, [debouncedLoad]),
  };
}

Search & Filter Integration

Paginated Search Results

// components/search-results.tsx
export function SearchResults() {
  const [searchTerm, setSearchTerm] = useState('');
  const [filters, setFilters] = useState<FilterOptions>({});
  const [sortBy, setSortBy] = useState('RELEVANCE');

  const {
    data,
    loading,
    error,
    fetchMore,
    refetch,
  } = useQuery(SEARCH_PRODUCTS, {
    variables: {
      searchTerm,
      filters,
      sortBy,
      first: 20,
    },
    skip: !searchTerm,
  });

  // Reset pagination on search/filter change
  useEffect(() => {
    refetch({
      searchTerm,
      filters,
      sortBy,
      first: 20,
      after: null,
    });
  }, [searchTerm, filters, sortBy]);

  const handleLoadMore = async () => {
    await fetchMore({
      variables: {
        after: data?.searchProducts?.pageInfo?.endCursor,
      },
    });
  };

  return (
    <div>
      <SearchBar
        value={searchTerm}
        onChange={setSearchTerm}
        onSearch={() => refetch()}
      />

      <FilterPanel
        filters={filters}
        onChange={setFilters}
        availableFacets={data?.searchProducts?.facets}
      />

      <SortDropdown
        value={sortBy}
        onChange={setSortBy}
        options={['RELEVANCE', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST']}
      />

      <SearchResultsList
        results={data?.searchProducts?.edges || []}
        loading={loading}
        onLoadMore={handleLoadMore}
        hasMore={data?.searchProducts?.pageInfo?.hasNextPage}
      />

      <SearchPagination
        pageInfo={data?.searchProducts?.pageInfo}
        totalCount={data?.searchProducts?.totalCount}
      />
    </div>
  );
}

Faceted Search with Pagination

// components/faceted-search.tsx
interface FacetedSearchProps {
  category?: string;
  initialFilters?: FilterOptions;
}

export function FacetedSearch({ category, initialFilters }: FacetedSearchProps) {
  const [appliedFilters, setAppliedFilters] = useState(initialFilters || {});
  const [pendingFilters, setPendingFilters] = useState(initialFilters || {});
  const [isFilterOpen, setIsFilterOpen] = useState(false);

  const {
    products,
    facets,
    loading,
    pageInfo,
    refetch,
    fetchMore,
  } = useFacetedSearch({
    category,
    filters: appliedFilters,
  });

  const applyFilters = () => {
    setAppliedFilters(pendingFilters);
    setIsFilterOpen(false);
    // Reset to first page
    refetch({
      filters: pendingFilters,
      first: 20,
      after: null,
    });
  };

  const clearFilters = () => {
    setPendingFilters({});
    setAppliedFilters({});
    refetch({
      filters: {},
      first: 20,
      after: null,
    });
  };

  return (
    <div className="faceted-search">
      <div className="filter-sidebar">
        <h3>Filters</h3>

        {facets.map(facet => (
          <FacetGroup
            key={facet.name}
            facet={facet}
            selected={pendingFilters[facet.name]}
            onChange={(values) => {
              setPendingFilters({
                ...pendingFilters,
                [facet.name]: values,
              });
            }}
          />
        ))}

        <button onClick={applyFilters}>Apply Filters</button>
        <button onClick={clearFilters}>Clear All</button>
      </div>

      <div className="results">
        <div className="results-header">
          <p>{products.totalCount} results</p>
          <ActiveFilters
            filters={appliedFilters}
            onRemove={(key) => {
              const newFilters = { ...appliedFilters };
              delete newFilters[key];
              setAppliedFilters(newFilters);
              refetch({ filters: newFilters });
            }}
          />
        </div>

        <ProductGrid products={products} />

        <LoadMoreButton
          onClick={() => fetchMore({ after: pageInfo.endCursor })}
          hasMore={pageInfo.hasNextPage}
          loading={loading}
        />
      </div>
    </div>
  );
}

Real-World Examples

E-commerce Product Catalog

// pages/products.tsx
export function ProductCatalogPage() {
  const router = useRouter();
  const { category, page, sort, ...filters } = router.query;

  const {
    products,
    totalCount,
    pageInfo,
    loading,
    error,
  } = useProducts({
    category: category as string,
    filters: parseFilters(filters),
    sortBy: parseSortBy(sort as string),
    page: parseInt(page as string) || 1,
  });

  const updateURL = (params: URLSearchParams) => {
    router.push({
      pathname: router.pathname,
      query: params.toString(),
    }, undefined, { shallow: true });
  };

  const handlePageChange = (newPage: number) => {
    const params = new URLSearchParams(router.query as any);
    params.set('page', newPage.toString());
    updateURL(params);
  };

  const handleFilterChange = (newFilters: FilterOptions) => {
    const params = new URLSearchParams();
    params.set('category', category as string);

    Object.entries(newFilters).forEach(([key, value]) => {
      if (value) {
        params.set(key, Array.isArray(value) ? value.join(',') : value);
      }
    });

    params.delete('page'); // Reset to page 1
    updateURL(params);
  };

  return (
    <Layout>
      <div className="catalog-page">
        <Breadcrumbs category={category as string} />

        <div className="catalog-header">
          <h1>{category || 'All Products'}</h1>
          <p>{totalCount} products</p>
        </div>

        <div className="catalog-body">
          <FilterSidebar
            filters={filters}
            onChange={handleFilterChange}
          />

          <div className="catalog-main">
            <SortBar
              value={sort as string}
              onChange={(newSort) => {
                const params = new URLSearchParams(router.query as any);
                params.set('sort', newSort);
                updateURL(params);
              }}
            />

            {loading && <LoadingGrid />}
            {error && <ErrorMessage error={error} />}

            <ProductGrid products={products} />

            <Pagination
              currentPage={parseInt(page as string) || 1}
              totalPages={Math.ceil(totalCount / 20)}
              onPageChange={handlePageChange}
            />
          </div>
        </div>
      </div>
    </Layout>
  );
}

Order History

// components/order-history.tsx
export function OrderHistory({ customerId }: { customerId: string }) {
  const [timeRange, setTimeRange] = useState('LAST_30_DAYS');
  const [status, setStatus] = useState<OrderStatus | null>(null);

  const {
    orders,
    loading,
    hasMore,
    loadMore,
    refetch,
  } = useOrders({
    customerId,
    timeRange,
    status,
  });

  // Group orders by date
  const groupedOrders = useMemo(() => {
    return orders.reduce((groups, order) => {
      const date = format(new Date(order.createdAt), 'MMMM yyyy');

      if (!groups[date]) {
        groups[date] = [];
      }

      groups[date].push(order);
      return groups;
    }, {} as Record<string, Order[]>);
  }, [orders]);

  return (
    <div className="order-history">
      <div className="filters">
        <TimeRangeSelect
          value={timeRange}
          onChange={(value) => {
            setTimeRange(value);
            refetch();
          }}
        />

        <OrderStatusSelect
          value={status}
          onChange={(value) => {
            setStatus(value);
            refetch();
          }}
        />
      </div>

      {Object.entries(groupedOrders).map(([date, dateOrders]) => (
        <div key={date} className="order-group">
          <h3>{date}</h3>

          {dateOrders.map(order => (
            <OrderCard key={order.id} order={order} />
          ))}
        </div>
      ))}

      {loading && <LoadingSpinner />}

      {hasMore && (
        <button onClick={loadMore} className="load-more">
          Load More Orders
        </button>
      )}

      {!loading && orders.length === 0 && (
        <EmptyState message="No orders found" />
      )}
    </div>
  );
}

Troubleshooting

Common Issues

1. Duplicate Items

Problem: Same items appear multiple times Solution:

// Deduplicate by ID
const deduplicateEdges = (edges: Edge[]) => {
  const seen = new Set();
  return edges.filter(edge => {
    if (seen.has(edge.node.id)) {
      return false;
    }
    seen.add(edge.node.id);
    return true;
  });
};

// In updateQuery
updateQuery: (prev, { fetchMoreResult }) => ({
  products: {
    ...fetchMoreResult.products,
    edges: deduplicateEdges([
      ...prev.products.edges,
      ...fetchMoreResult.products.edges,
    ]),
  },
});

2. Lost Scroll Position

Problem: Scroll jumps when loading more Solution:

// Preserve scroll position
const preserveScrollPosition = () => {
  const scrollY = window.scrollY;

  return () => {
    requestAnimationFrame(() => {
      window.scrollTo(0, scrollY);
    });
  };
};

// Use when loading
const restoreScroll = preserveScrollPosition();
await loadMore();
restoreScroll();

3. Stale Pagination State

Problem: Page info outdated after mutations Solution:

// Invalidate pagination cache
const handleDelete = async (id: string) => {
  await deleteItem(id);

  // Refetch with fresh pagination
  await refetch({
    first: pageSize,
    after: null, // Reset to first page
  });
};

4. Memory Leaks with Large Lists

Problem: Browser slows down with many items Solution:

// Implement windowing
const WINDOW_SIZE = 100;

const windowedProducts = products.slice(
  Math.max(0, currentIndex - WINDOW_SIZE / 2),
  currentIndex + WINDOW_SIZE / 2
);

Best Practices

  1. Always provide loading states for better UX
  2. Implement error boundaries for graceful failures
  3. Use virtual scrolling for lists with 1000+ items
  4. Cache page data appropriately
  5. Provide keyboard navigation for accessibility
  6. Show total count when available
  7. Reset pagination when filters change
  8. Prefetch next page for better performance
  9. Use debouncing for rapid navigation
  10. Test with slow connections to ensure good UX

Resources

Was this page helpful?