skip to content

With Relay's usePaginationFragment, what must a news article's comment-thread fragment declare, and how does loadNext add the next page?

level: middleimportance: should knowfreq 27%

answer

  1. three directives on one fragment
  2. a pagination query you never write
  3. the key ends with the field name
  4. same variables except count and cursor

basics

~20 s

The fragment needs @argumentDefinitions for count and cursor, @refetchable(queryName) so the compiler generates a pagination query, and @connection(key) on the comments field. loadNext(n) sends that query from the current end cursor, and Relay appends the new edges to the stored connection.

solid answer

~40 s

The `CommentThread_article` fragment declares `@argumentDefinitions(count: { type: "Int", defaultValue: 10 }, cursor: { type: "String" })`, `@refetchable(queryName: "CommentThreadPaginationQuery")`, and on the field `comments(first: $count, after: $cursor) @connection(key: "CommentThread_comments")`. The compiler generates the pagination query, a `node(id:)` query because `Article` implements `Node`, and adds `cursor` and `pageInfo` to the selection. `usePaginationFragment` returns `data`, `loadNext`, `hasNext` and `isLoadingNext`. `loadNext(10)` sends the generated query with `count: 10` and `cursor` set to the connection's end cursor, keeping every other variable, and Relay's connection handler appends the new edges to one connection record identified by the article, the key and any filter arguments. Without `@connection` or `@refetchable` the hook throws.

code

tsx · 28 lines
tsx
import { graphql, usePaginationFragment } from 'react-relay';
import { startTransition } from 'react';
import type { CommentThread_article$key } from './__generated__/CommentThread_article.graphql';

export function CommentThread({ article }: { article: CommentThread_article$key }) {
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    graphql`
      fragment CommentThread_article on Article
      @argumentDefinitions(count: { type: "Int", defaultValue: 10 }, cursor: { type: "String" })
      @refetchable(queryName: "CommentThreadPaginationQuery") {
        comments(first: $count, after: $cursor) @connection(key: "CommentThread_comments") {
          edges { node { id ...CommentRow_comment } }
        }
      }
    `,
    article,
  );
  return (
    <section>
      {data.comments?.edges?.map((e) => e?.node && <CommentRow key={e.node.id} comment={e.node} />)}
      {hasNext && (
        <button disabled={isLoadingNext} onClick={() => startTransition(() => { loadNext(10); })}>
          Load more comments
        </button>
      )}
    </section>
  );
}

go deeper

for a junior

Recall the three directives: @argumentDefinitions for count and cursor, @refetchable with a queryName, and @connection with a key ending in the field name.

for a middle

Explain what loadNext sends, how the connection handler appends edges to one record, and how filters and the parent id make each connection distinct.

for a senior

Show judgment on nested threads: separate fragments per level, Node types for refetchability, transitions around loadNext, and how mutations locate the connection by __id or getConnectionID.

for a principal

Relate the design to schema contracts: Relay's pagination only works when the API follows cursor-connection and Node conventions, which is a decision for the API and client teams together.

## The scenario A news article carries a long comment thread. It shows ten comments, then a "Load more comments" button; each comment has replies, shown the same way. In Relay this is `usePaginationFragment` over a **connection**, a list modelled with `edges`, `node`, `cursor` and `pageInfo`. ## What the fragment must declare | Piece | Example | Rule | |---|---|---| | `@argumentDefinitions` | `count: { type: "Int", defaultValue: 10 }`, `cursor: { type: "String" }` | the page-size and cursor variables the fragment uses | | `@refetchable` | `@refetchable(queryName: "CommentThreadPaginationQuery")` | only on a fragment on `Query`, `Viewer`, or a type implementing `Node`; `queryName` must be unique | | `@connection` | `comments(first: $count, after: $cursor) @connection(key: "CommentThread_comments")` | the key must end with `_` plus the field name, here `_comments` | If the fragment has no `@refetchable`, or no `@connection`, `usePaginationFragment` throws an invariant error when it runs ("Did you forget to add a @refetchable directive to the fragment?"). ## What the compiler generates - **The pagination query.** Because `Article` implements `Node`, the compiler writes `CommentThreadPaginationQuery` as a `node(id: $id)` query that spreads the fragment with the new `count` and `cursor`. You never write it. - **Connection plumbing.** It adds `cursor` to each edge and, for a forward connection like this one, `pageInfo { endCursor hasNextPage }` (the backward fields when a connection pages backwards), so the runtime can page. - **Types** for the fragment's data and the generated query. ## What loadNext does 1. The component calls `loadNext(10)`. 2. Relay builds variables from the ones that originally fetched the connection, changing only the pagination variables: `count: 10`, `cursor` set to the stored `pageInfo.endCursor`, and `id` of the article. 3. It sends the generated query. The component does **not** suspend; `isLoadingNext` becomes `true`. 4. The response is normalized. Relay's **connection handler** merges the new edges into the existing connection record, appending them after the current ones, and updates `pageInfo`. 5. The fragment re-renders with twenty comments; `hasNext` reflects the new `hasNextPage`. `loadNext` returns a disposable whose `dispose()` cancels the request, and accepts an `onComplete` callback. `loadPrevious`, `hasPrevious` and `isLoadingPrevious` mirror it backwards, and `refetch(vars)` re-runs the connection with new variables. ## Connection identity in the store A connection record is identified by the **parent record's id**, the **key** and the values of its **filter** arguments, meaning every non-pagination argument. If the thread takes `orderBy: TOP` or `NEWEST`, each ordering is its own record, so switching the sort never appends newest comments onto the top list. `@connection(key: ..., filters: ["orderBy"])` names exactly which arguments count, useful when an argument such as a translation language does not change the set of items. The same identity is how mutations find the list later: select `__id` on the connection, or call `ConnectionHandler.getConnectionID(articleId, "CommentThread_comments", filters)`. ## Nested threads: replies per comment Replies are a second connection, one per comment. Give the reply list its own component and fragment, `CommentReplies_comment on Comment`, with its own `@refetchable` and `@connection(key: "CommentReplies_replies")`. Because `Comment` implements `Node`, each comment's replies page independently through a generated `node(id:)` query, and a reader expanding replies under one comment never refetches the thread. ## Refetching the thread with different variables The hook also returns `refetch(variables, options)`, which re-runs the same generated query with new values for the fragment's variables. Switching the thread from top comments to newest is `refetch({ orderBy: 'NEWEST' })`; variables you omit keep their original values, and the article's `id` is filled in automatically because the fragment is refetchable. Unlike `loadNext`, `refetch` can suspend, so it too belongs in a transition when visible content should stay on screen. Because `orderBy` is a filter argument, the newest-first list is stored as its own connection record, and switching back to top comments can be served from the store. ## Loading states - Render the button while `hasNext` is true and disable it while `isLoadingNext` is true. - The API reference says `loadNext` does not suspend the paginating component, while the pagination guide notes that newly rendered child components can suspend and recommends wrapping `loadNext` in `startTransition`. Both hold: the list itself shows `isLoadingNext`, and a transition keeps new children's suspensions from replacing visible content with a fallback.

  • The Relay comment thread can be sorted by TOP or NEWEST. How are the two orderings stored?
    Every non-pagination argument of a `@connection` field is a filter and part of the connection's identity, so `orderBy: TOP` and `orderBy: NEWEST` are two separate connection records under the article. Paging one never appends to the other. `filters: ["orderBy"]` on `@connection` restricts identity to the arguments that really change the item set.
  • How do you paginate the replies under each Relay comment without refetching the thread?
    Give the replies their own fragment on `Comment` with `@refetchable` and a `@connection` keyed like `CommentReplies_replies`, read with a second `usePaginationFragment`. `Comment` implements `Node`, so the compiler generates a `node(id:)` pagination query per comment, and each reply list pages on its own.

saying these in an interview costs you the question

  • You write the pagination query by hand and pass it to usePaginationFragment.
  • loadNext replaces the rendered comments with the next page.
  • Any unique string works as the @connection key.
  • @refetchable works on a fragment on any type, even one without an id.
  • Changing the orderBy argument keeps appending to the same stored list.