relay-pagination

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Relay Pagination

Relay 分页

Master Relay's cursor-based pagination for efficiently loading and displaying large datasets with infinite scroll and load more patterns.
掌握Relay的基于游标分页,实现通过无限滚动和加载更多模式高效加载与展示大型数据集。

Overview

概述

Relay implements the GraphQL Cursor Connections Specification for efficient pagination. It provides hooks like usePaginationFragment for declarative pagination with automatic cache updates and connection management.
Relay 实现了GraphQL游标连接规范以实现高效分页。它提供了usePaginationFragment等钩子,用于声明式分页,支持自动缓存更新和连接管理。

Installation and Setup

安装与配置

Pagination Query Structure

分页查询结构

graphql
undefined
graphql
undefined

schema.graphql

schema.graphql

type Query { posts( first: Int after: String last: Int before: String ): PostConnection! }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int }
type PostEdge { cursor: String! node: Post! }
type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String }
type Post { id: ID! title: String! body: String! }
undefined
type Query { posts( first: Int after: String last: Int before: String ): PostConnection! }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! totalCount: Int }
type PostEdge { cursor: String! node: Post! }
type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String }
type Post { id: ID! title: String! body: String! }
undefined

Core Patterns

核心模式

1. Basic Pagination

1. 基础分页

javascript
// PostsList.jsx
import { graphql, usePaginationFragment } from 'react-relay';

const PostsListFragment = graphql`
  fragment PostsList_query on Query
  @refetchable(queryName: "PostsListPaginationQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "PostsList_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostsList({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(PostsListFragment, query);

  return (
    <div>
      <button
        onClick={() => refetch({ first: 10 })}
        disabled={isLoadingNext}
      >
        Refresh
      </button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

export default PostsList;
javascript
// PostsList.jsx
import { graphql, usePaginationFragment } from 'react-relay';

const PostsListFragment = graphql`
  fragment PostsList_query on Query
  @refetchable(queryName: "PostsListPaginationQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "PostsList_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostsList({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(PostsListFragment, query);

  return (
    <div>
      <button
        onClick={() => refetch({ first: 10 })}
        disabled={isLoadingNext}
      >
        刷新
      </button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

export default PostsList;

2. Infinite Scroll

2. 无限滚动

javascript
// InfiniteScrollPosts.jsx
import { useEffect, useRef } from 'react';
import { graphql, usePaginationFragment } from 'react-relay';

const InfiniteScrollFragment = graphql`
  fragment InfiniteScrollPosts_query on Query
  @refetchable(queryName: "InfiniteScrollPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 20 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "InfiniteScroll_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function InfiniteScrollPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    InfiniteScrollFragment,
    query
  );

  const observerRef = useRef();
  const loadMoreRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
          loadNext(20);
        }
      },
      { threshold: 0.5 }
    );

    const currentRef = loadMoreRef.current;
    if (currentRef) {
      observer.observe(currentRef);
    }

    observerRef.current = observer;

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [hasNext, isLoadingNext, loadNext]);

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <div ref={loadMoreRef} className="load-more-trigger">
          {isLoadingNext && <Spinner />}
        </div>
      )}

      {!hasNext && <div>No more posts</div>}
    </div>
  );
}
javascript
// InfiniteScrollPosts.jsx
import { useEffect, useRef } from 'react';
import { graphql, usePaginationFragment } from 'react-relay';

const InfiniteScrollFragment = graphql`
  fragment InfiniteScrollPosts_query on Query
  @refetchable(queryName: "InfiniteScrollPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 20 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "InfiniteScroll_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function InfiniteScrollPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    InfiniteScrollFragment,
    query
  );

  const observerRef = useRef();
  const loadMoreRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
          loadNext(20);
        }
      },
      { threshold: 0.5 }
    );

    const currentRef = loadMoreRef.current;
    if (currentRef) {
      observer.observe(currentRef);
    }

    observerRef.current = observer;

    return () => {
      if (currentRef) {
        observer.unobserve(currentRef);
      }
    };
  }, [hasNext, isLoadingNext, loadNext]);

  return (
    <div>
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <div ref={loadMoreRef} className="load-more-trigger">
          {isLoadingNext && <Spinner />}
        </div>
      )}

      {!hasNext && <div>没有更多帖子了</div>}
    </div>
  );
}

3. Bidirectional Pagination

3. 双向分页

javascript
// BidirectionalPosts.jsx
const BidirectionalFragment = graphql`
  fragment BidirectionalPosts_query on Query
  @refetchable(queryName: "BidirectionalPostsQuery")
  @argumentDefinitions(
    first: { type: "Int" }
    after: { type: "String" }
    last: { type: "Int" }
    before: { type: "String" }
  ) {
    posts(first: $first, after: $after, last: $last, before: $before)
    @connection(key: "Bidirectional_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
    }
  }
`;

function BidirectionalPosts({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious
  } = usePaginationFragment(BidirectionalFragment, query);

  return (
    <div>
      {hasPrevious && (
        <button
          onClick={() => loadPrevious(10)}
          disabled={isLoadingPrevious}
        >
          {isLoadingPrevious ? 'Loading...' : 'Load Previous'}
        </button>
      )}

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? 'Loading...' : 'Load Next'}
        </button>
      )}
    </div>
  );
}
javascript
// BidirectionalPosts.jsx
const BidirectionalFragment = graphql`
  fragment BidirectionalPosts_query on Query
  @refetchable(queryName: "BidirectionalPostsQuery")
  @argumentDefinitions(
    first: { type: "Int" }
    after: { type: "String" }
    last: { type: "Int" }
    before: { type: "String" }
  ) {
    posts(first: $first, after: $after, last: $last, before: $before)
    @connection(key: "Bidirectional_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
    }
  }
`;

function BidirectionalPosts({ query }) {
  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious
  } = usePaginationFragment(BidirectionalFragment, query);

  return (
    <div>
      {hasPrevious && (
        <button
          onClick={() => loadPrevious(10)}
          disabled={isLoadingPrevious}
        >
          {isLoadingPrevious ? '加载中...' : '加载上一页'}
        </button>
      )}

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button
          onClick={() => loadNext(10)}
          disabled={isLoadingNext}
        >
          {isLoadingNext ? '加载中...' : '加载下一页'}
        </button>
      )}
    </div>
  );
}

4. Filtered Pagination

4. 带筛选的分页

javascript
// FilteredPosts.jsx
const FilteredPostsFragment = graphql`
  fragment FilteredPosts_query on Query
  @refetchable(queryName: "FilteredPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    status: { type: "PostStatus" }
    authorId: { type: "ID" }
  ) {
    posts(
      first: $first
      after: $after
      status: $status
      authorId: $authorId
    )
    @connection(key: "FilteredPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function FilteredPosts({ query }) {
  const [status, setStatus] = useState('PUBLISHED');
  const [authorId, setAuthorId] = useState(null);

  const { data, loadNext, hasNext, refetch } = usePaginationFragment(
    FilteredPostsFragment,
    query
  );

  const handleFilterChange = (newStatus, newAuthorId) => {
    setStatus(newStatus);
    setAuthorId(newAuthorId);

    refetch({
      first: 10,
      after: null,
      status: newStatus,
      authorId: newAuthorId
    });
  };

  return (
    <div>
      <FilterControls
        status={status}
        authorId={authorId}
        onChange={handleFilterChange}
      />

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)}>Load More</button>
      )}
    </div>
  );
}
javascript
// FilteredPosts.jsx
const FilteredPostsFragment = graphql`
  fragment FilteredPosts_query on Query
  @refetchable(queryName: "FilteredPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    status: { type: "PostStatus" }
    authorId: { type: "ID" }
  ) {
    posts(
      first: $first
      after: $after
      status: $status
      authorId: $authorId
    )
    @connection(key: "FilteredPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function FilteredPosts({ query }) {
  const [status, setStatus] = useState('PUBLISHED');
  const [authorId, setAuthorId] = useState(null);

  const { data, loadNext, hasNext, refetch } = usePaginationFragment(
    FilteredPostsFragment,
    query
  );

  const handleFilterChange = (newStatus, newAuthorId) => {
    setStatus(newStatus);
    setAuthorId(newAuthorId);

    refetch({
      first: 10,
      after: null,
      status: newStatus,
      authorId: newAuthorId
    });
  };

  return (
    <div>
      <FilterControls
        status={status}
        authorId={authorId}
        onChange={handleFilterChange}
      />

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)}>加载更多</button>
      )}
    </div>
  );
}

5. Pagination with Search

5. 带搜索的分页

javascript
// SearchablePosts.jsx
const SearchablePostsFragment = graphql`
  fragment SearchablePosts_query on Query
  @refetchable(queryName: "SearchablePostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    searchTerm: { type: "String" }
  ) {
    posts(first: $first, after: $after, searchTerm: $searchTerm)
    @connection(key: "SearchablePosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      totalCount
    }
  }
`;

function SearchablePosts({ query }) {
  const [searchTerm, setSearchTerm] = useState('');
  const { data, loadNext, hasNext, refetch, isLoadingNext } =
    usePaginationFragment(SearchablePostsFragment, query);

  const handleSearch = (term) => {
    setSearchTerm(term);
    refetch({
      first: 10,
      after: null,
      searchTerm: term
    });
  };

  return (
    <div>
      <SearchInput
        value={searchTerm}
        onChange={handleSearch}
        placeholder="Search posts..."
      />

      <div>
        Showing {data.posts.edges.length} of {data.posts.totalCount} posts
      </div>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)} disabled={isLoadingNext}>
          {isLoadingNext ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}
javascript
// SearchablePosts.jsx
const SearchablePostsFragment = graphql`
  fragment SearchablePosts_query on Query
  @refetchable(queryName: "SearchablePostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
    searchTerm: { type: "String" }
  ) {
    posts(first: $first, after: $after, searchTerm: $searchTerm)
    @connection(key: "SearchablePosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
      totalCount
    }
  }
`;

function SearchablePosts({ query }) {
  const [searchTerm, setSearchTerm] = useState('');
  const { data, loadNext, hasNext, refetch, isLoadingNext } =
    usePaginationFragment(SearchablePostsFragment, query);

  const handleSearch = (term) => {
    setSearchTerm(term);
    refetch({
      first: 10,
      after: null,
      searchTerm: term
    });
  };

  return (
    <div>
      <SearchInput
        value={searchTerm}
        onChange={handleSearch}
        placeholder="搜索帖子..."
      />

      <div>
{data.posts.totalCount} 个帖子,当前显示 {data.posts.edges.length}      </div>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={() => loadNext(10)} disabled={isLoadingNext}>
          {isLoadingNext ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

6. Optimistic Pagination Updates

6. 乐观更新分页

javascript
// OptimisticPaginationPosts.jsx
const CreatePostMutation = graphql`
  mutation OptimisticPaginationCreatePostMutation(
    $input: CreatePostInput!
    $connections: [ID!]!
  ) {
    createPost(input: $input) {
      postEdge @prependEdge(connections: $connections) {
        cursor
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function OptimisticPaginationPosts({ query }) {
  const { data } = usePaginationFragment(PostsFragment, query);
  const [commit] = useMutation(CreatePostMutation);

  const connectionID = ConnectionHandler.getConnectionID(
    'client:root',
    'Posts_posts'
  );

  const handleCreate = (title, body) => {
    commit({
      variables: {
        input: { title, body },
        connections: [connectionID]
      },

      optimisticResponse: {
        createPost: {
          postEdge: {
            cursor: 'temp-cursor',
            node: {
              id: `temp-${Date.now()}`,
              title,
              body,
              createdAt: new Date().toISOString(),
              author: {
                id: currentUser.id,
                name: currentUser.name
              }
            }
          }
        }
      }
    });
  };

  return (
    <div>
      <CreatePostForm onSubmit={handleCreate} />
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}
javascript
// OptimisticPaginationPosts.jsx
const CreatePostMutation = graphql`
  mutation OptimisticPaginationCreatePostMutation(
    $input: CreatePostInput!
    $connections: [ID!]!
  ) {
    createPost(input: $input) {
      postEdge @prependEdge(connections: $connections) {
        cursor
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function OptimisticPaginationPosts({ query }) {
  const { data } = usePaginationFragment(PostsFragment, query);
  const [commit] = useMutation(CreatePostMutation);

  const connectionID = ConnectionHandler.getConnectionID(
    'client:root',
    'Posts_posts'
  );

  const handleCreate = (title, body) => {
    commit({
      variables: {
        input: { title, body },
        connections: [connectionID]
      },

      optimisticResponse: {
        createPost: {
          postEdge: {
            cursor: 'temp-cursor',
            node: {
              id: `temp-${Date.now()}`,
              title,
              body,
              createdAt: new Date().toISOString(),
              author: {
                id: currentUser.id,
                name: currentUser.name
              }
            }
          }
        }
      }
    });
  };

  return (
    <div>
      <CreatePostForm onSubmit={handleCreate} />
      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}

7. Paginated Tabs

7. 分页标签页

javascript
// TabbedPosts.jsx
const TabbedPostsFragment = graphql`
  fragment TabbedPosts_user on User
  @refetchable(queryName: "TabbedPostsQuery")
  @argumentDefinitions(
    draftsFirst: { type: "Int", defaultValue: 10 }
    draftsAfter: { type: "String" }
    publishedFirst: { type: "Int", defaultValue: 10 }
    publishedAfter: { type: "String" }
  ) {
    draftPosts: posts(
      first: $draftsFirst
      after: $draftsAfter
      status: DRAFT
    )
    @connection(key: "TabbedPosts_draftPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }

    publishedPosts: posts(
      first: $publishedFirst
      after: $publishedAfter
      status: PUBLISHED
    )
    @connection(key: "TabbedPosts_publishedPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function TabbedPosts({ user }) {
  const [activeTab, setActiveTab] = useState('published');
  const { data } = usePaginationFragment(TabbedPostsFragment, user);

  const posts =
    activeTab === 'draft' ? data.draftPosts : data.publishedPosts;

  return (
    <div>
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tab value="published">Published</Tab>
        <Tab value="draft">Drafts</Tab>
      </Tabs>

      {posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}
javascript
// TabbedPosts.jsx
const TabbedPostsFragment = graphql`
  fragment TabbedPosts_user on User
  @refetchable(queryName: "TabbedPostsQuery")
  @argumentDefinitions(
    draftsFirst: { type: "Int", defaultValue: 10 }
    draftsAfter: { type: "String" }
    publishedFirst: { type: "Int", defaultValue: 10 }
    publishedAfter: { type: "String" }
  ) {
    draftPosts: posts(
      first: $draftsFirst
      after: $draftsAfter
      status: DRAFT
    )
    @connection(key: "TabbedPosts_draftPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }

    publishedPosts: posts(
      first: $publishedFirst
      after: $publishedAfter
      status: PUBLISHED
    )
    @connection(key: "TabbedPosts_publishedPosts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function TabbedPosts({ user }) {
  const [activeTab, setActiveTab] = useState('published');
  const { data } = usePaginationFragment(TabbedPostsFragment, user);

  const posts =
    activeTab === 'draft' ? data.draftPosts : data.publishedPosts;

  return (
    <div>
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tab value="published">已发布</Tab>
        <Tab value="draft">草稿</Tab>
      </Tabs>

      {posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
    </div>
  );
}

8. Virtual Scrolling with Pagination

8. 虚拟滚动结合分页

javascript
// VirtualizedPosts.jsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { graphql, usePaginationFragment } from 'react-relay';

const VirtualizedPostsFragment = graphql`
  fragment VirtualizedPosts_query on Query
  @refetchable(queryName: "VirtualizedPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 50 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "VirtualizedPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function VirtualizedPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    VirtualizedPostsFragment,
    query
  );

  const parentRef = useRef();
  const posts = data.posts.edges.map(e => e.node);

  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
    overscan: 5
  });

  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

    if (!lastItem) return;

    if (
      lastItem.index >= posts.length - 1 &&
      hasNext &&
      !isLoadingNext
    ) {
      loadNext(50);
    }
  }, [
    hasNext,
    loadNext,
    isLoadingNext,
    posts.length,
    virtualizer.getVirtualItems()
  ]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`
            }}
          >
            <PostCard post={posts[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
javascript
// VirtualizedPosts.jsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { graphql, usePaginationFragment } from 'react-relay';

const VirtualizedPostsFragment = graphql`
  fragment VirtualizedPosts_query on Query
  @refetchable(queryName: "VirtualizedPostsQuery")
  @argumentDefinitions(
    first: { type: "Int", defaultValue: 50 }
    after: { type: "String" }
  ) {
    posts(first: $first, after: $after)
    @connection(key: "VirtualizedPosts_posts") {
      edges {
        node {
          id
          ...PostCard_post
        }
      }
    }
  }
`;

function VirtualizedPosts({ query }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    VirtualizedPostsFragment,
    query
  );

  const parentRef = useRef();
  const posts = data.posts.edges.map(e => e.node);

  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
    overscan: 5
  });

  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();

    if (!lastItem) return;

    if (
      lastItem.index >= posts.length - 1 &&
      hasNext &&
      !isLoadingNext
    ) {
      loadNext(50);
    }
  }, [
    hasNext,
    loadNext,
    isLoadingNext,
    posts.length,
    virtualizer.getVirtualItems()
  ]);

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`
            }}
          >
            <PostCard post={posts[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

9. Pagination State Management

9. 分页状态管理

javascript
// PaginationStateManager.jsx
function PaginationStateManager({ query }) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext,
    refetch
  } = usePaginationFragment(PostsFragment, query);

  const [paginationState, setPaginationState] = useState({
    currentPage: 1,
    itemsPerPage: 10,
    totalLoaded: 0
  });

  const handleLoadMore = () => {
    const itemsToLoad = paginationState.itemsPerPage;
    loadNext(itemsToLoad);

    setPaginationState(prev => ({
      ...prev,
      currentPage: prev.currentPage + 1,
      totalLoaded: prev.totalLoaded + itemsToLoad
    }));
  };

  const handleChangePageSize = (newSize) => {
    setPaginationState(prev => ({
      ...prev,
      itemsPerPage: newSize
    }));

    refetch({
      first: newSize,
      after: null
    });
  };

  return (
    <div>
      <div>
        Page {paginationState.currentPage} -
        Loaded {paginationState.totalLoaded} items
      </div>

      <select
        value={paginationState.itemsPerPage}
        onChange={(e) => handleChangePageSize(Number(e.target.value))}
      >
        <option value={10}>10 per page</option>
        <option value={25}>25 per page</option>
        <option value={50}>50 per page</option>
      </select>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={handleLoadMore} disabled={isLoadingNext}>
          Load More
        </button>
      )}
    </div>
  );
}
javascript
// PaginationStateManager.jsx
function PaginationStateManager({ query }) {
  const {
    data,
    loadNext,
    hasNext,
    isLoadingNext,
    refetch
  } = usePaginationFragment(PostsFragment, query);

  const [paginationState, setPaginationState] = useState({
    currentPage: 1,
    itemsPerPage: 10,
    totalLoaded: 0
  });

  const handleLoadMore = () => {
    const itemsToLoad = paginationState.itemsPerPage;
    loadNext(itemsToLoad);

    setPaginationState(prev => ({
      ...prev,
      currentPage: prev.currentPage + 1,
      totalLoaded: prev.totalLoaded + itemsToLoad
    }));
  };

  const handleChangePageSize = (newSize) => {
    setPaginationState(prev => ({
      ...prev,
      itemsPerPage: newSize
    }));

    refetch({
      first: newSize,
      after: null
    });
  };

  return (
    <div>
      <div>
{paginationState.currentPage}-
        已加载 {paginationState.totalLoaded} 条数据
      </div>

      <select
        value={paginationState.itemsPerPage}
        onChange={(e) => handleChangePageSize(Number(e.target.value))}
      >
        <option value={10}>每页10</option>
        <option value={25}>每页25</option>
        <option value={50}>每页50</option>
      </select>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && (
        <button onClick={handleLoadMore} disabled={isLoadingNext}>
          加载更多
        </button>
      )}
    </div>
  );
}

10. Custom Pagination Hook

10. 自定义分页钩子

javascript
// hooks/usePagination.js
import { useState, useCallback } from 'react';
import { usePaginationFragment } from 'react-relay';

export function usePagination(fragment, fragmentRef, options = {}) {
  const {
    onLoadMore,
    onLoadPrevious,
    onRefetch,
    pageSize = 10
  } = options;

  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(fragment, fragmentRef);

  const [page, setPage] = useState(1);

  const handleLoadNext = useCallback(() => {
    loadNext(pageSize);
    setPage(p => p + 1);
    onLoadMore?.();
  }, [loadNext, pageSize, onLoadMore]);

  const handleLoadPrevious = useCallback(() => {
    loadPrevious(pageSize);
    setPage(p => Math.max(1, p - 1));
    onLoadPrevious?.();
  }, [loadPrevious, pageSize, onLoadPrevious]);

  const handleRefetch = useCallback((variables) => {
    refetch(variables);
    setPage(1);
    onRefetch?.();
  }, [refetch, onRefetch]);

  return {
    data,
    page,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    loadNext: handleLoadNext,
    loadPrevious: handleLoadPrevious,
    refetch: handleRefetch
  };
}

// Usage
function PostsList({ query }) {
  const {
    data,
    page,
    hasNext,
    loadNext,
    refetch
  } = usePagination(PostsFragment, query, {
    pageSize: 20,
    onLoadMore: () => console.log('Loaded more'),
    onRefetch: () => console.log('Refetched')
  });

  return (
    <div>
      <div>Page {page}</div>
      <button onClick={() => refetch({ first: 20 })}>Refresh</button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && <button onClick={loadNext}>Load More</button>}
    </div>
  );
}
javascript
// hooks/usePagination.js
import { useState, useCallback } from 'react';
import { usePaginationFragment } from 'react-relay';

export function usePagination(fragment, fragmentRef, options = {}) {
  const {
    onLoadMore,
    onLoadPrevious,
    onRefetch,
    pageSize = 10
  } = options;

  const {
    data,
    loadNext,
    loadPrevious,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    refetch
  } = usePaginationFragment(fragment, fragmentRef);

  const [page, setPage] = useState(1);

  const handleLoadNext = useCallback(() => {
    loadNext(pageSize);
    setPage(p => p + 1);
    onLoadMore?.();
  }, [loadNext, pageSize, onLoadMore]);

  const handleLoadPrevious = useCallback(() => {
    loadPrevious(pageSize);
    setPage(p => Math.max(1, p - 1));
    onLoadPrevious?.();
  }, [loadPrevious, pageSize, onLoadPrevious]);

  const handleRefetch = useCallback((variables) => {
    refetch(variables);
    setPage(1);
    onRefetch?.();
  }, [refetch, onRefetch]);

  return {
    data,
    page,
    hasNext,
    hasPrevious,
    isLoadingNext,
    isLoadingPrevious,
    loadNext: handleLoadNext,
    loadPrevious: handleLoadPrevious,
    refetch: handleRefetch
  };
}

// 使用示例
function PostsList({ query }) {
  const {
    data,
    page,
    hasNext,
    loadNext,
    refetch
  } = usePagination(PostsFragment, query, {
    pageSize: 20,
    onLoadMore: () => console.log('已加载更多数据'),
    onRefetch: () => console.log('已重新获取数据')
  });

  return (
    <div>
      <div>{page}</div>
      <button onClick={() => refetch({ first: 20 })}>刷新</button>

      {data.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}

      {hasNext && <button onClick={loadNext}>加载更多</button>}
    </div>
  );
}

Best Practices

最佳实践

  1. Use @connection directive - Ensure proper cache updates
  2. Implement loading states - Show feedback during pagination
  3. Handle edge cases - Empty states, no more data
  4. Optimize page size - Balance UX and performance
  5. Use infinite scroll wisely - Consider virtual scrolling for large lists
  6. Implement search/filter - Allow users to narrow results
  7. Cache pagination state - Preserve scroll position
  8. Handle errors gracefully - Retry failed pagination requests
  9. Test pagination thoroughly - Edge cases, network failures
  10. Monitor performance - Track pagination metrics
  1. 使用@connection指令 - 确保缓存能正确更新
  2. 实现加载状态 - 分页过程中提供反馈
  3. 处理边界情况 - 空状态、无更多数据的场景
  4. 优化每页数据量 - 在用户体验和性能之间取得平衡
  5. 合理使用无限滚动 - 大型列表考虑使用虚拟滚动
  6. 实现搜索/筛选功能 - 允许用户缩小结果范围
  7. 缓存分页状态 - 保留滚动位置
  8. 优雅处理错误 - 重试失败的分页请求
  9. 全面测试分页功能 - 覆盖边界情况、网络故障场景
  10. 监控性能 - 跟踪分页相关指标

Common Pitfalls

常见陷阱

  1. Missing @connection directive - Cache updates fail
  2. Incorrect cursor management - Duplicate or missing items
  3. No loading states - Poor user experience
  4. Over-fetching - Requesting too many items per page
  5. Memory leaks - Not cleaning up observers
  6. Missing error handling - Failed requests break pagination
  7. Inconsistent page sizes - Confusing user experience
  8. Not handling empty states - Poor UX for no results
  9. Race conditions - Multiple concurrent pagination requests
  10. Missing accessibility - Keyboard navigation, screen readers
  1. 缺少@connection指令 - 缓存更新失败
  2. 游标管理错误 - 出现重复或缺失数据
  3. 无加载状态 - 用户体验差
  4. 过度获取数据 - 每页请求数据量过大
  5. 内存泄漏 - 未清理观察者
  6. 缺少错误处理 - 请求失败导致分页中断
  7. 每页数据量不一致 - 造成用户体验混乱
  8. 未处理空状态 - 无结果时体验差
  9. 竞态条件 - 多个并发分页请求冲突
  10. 缺少可访问性支持 - 键盘导航、屏幕阅读器适配不足

When to Use

适用场景

  • Displaying large lists of data
  • Building infinite scroll interfaces
  • Creating feed-based applications
  • Implementing search results
  • Building e-commerce product listings
  • Creating social media timelines
  • Developing comment threads
  • Building admin dashboards
  • Creating data tables
  • Implementing file browsers
  • 展示大型数据集列表
  • 构建无限滚动界面
  • 开发基于信息流的应用
  • 实现搜索结果展示
  • 构建电商产品列表
  • 开发社交媒体时间线
  • 实现评论线程
  • 构建管理后台仪表盘
  • 开发数据表格
  • 实现文件浏览器

Resources

参考资源