一覧へ

Relayを使って最小限の努力でページネーションをする

GraphQLのConnectionベースのページネーションは確かに良いアイディアですが、Relayを使うことでこの仕様を最大限活用し、重複するコードを限界まで排除できます。 この翻訳記事ではRelayがどのようにConnectionベースのページネーションを処理するのか、詳細を解説していきます。

(原文)


Relay入門シリーズ(全4記事)
Relay:あなたのために泥臭い仕事をしてくれるGraphQLクライアント
GraphQLでのConnectionベースのページネーション
Relayを使って最小限の努力でページネーションをする(本記事)
Nodeインターフェースの魔法

この連載は、Gabriel NordebornSean Groveが執筆しています。GabrielはスウェーデンのITコンサルタント会社Arizonのフロントエンド開発者でありパートナーでもあり、長い間Relayを利用してきました。SeanはサードパーティのAPIをGraphQLで統一するOneGraph.comの共同創業者です。

ページネーション。それは誰もが最終的に辿り着くもので、そして正直に言えば楽しいものではありません。この記事では、いくつかの規則に従えば、Relayでのページネーションは楽しくないかもしれませんが簡単で人間工学に基づいたものであることを紹介します。

この記事では、フィルタを使用せずに前方へのページネーションのみを行うシンプルなページネーションに焦点を当てます。しかし、Relayでは後方へのページネーションも同様に簡単に行うことができ、フィルターも美しく処理できます。この2つについてはこちらをご覧ください

また、Relayでのページネーションを最大限に実現するためには、お使いのGraphQLサーバが以下の2つのGraphQLのベストプラクティスに従う必要があります。

この記事ではまず身近なアプリの例を紹介し、次に必要なページネーションを実装する上での課題を説明します。そして、これらの課題に対するRelayのソリューションを説明します。

GraphQLクライアントでは、通常どのようにページネーションが行われるのか?

通常、ページネーションは次のように構成されます。

  1. 通常は何らかのquery(典型的には現在見ているビューのメインのquery)から、何らかの形式でアイテムの最初のリストを取得します。このqueryには、通常はページネーションしたいリストのアイテムに加えて、他にも多くのものが含まれています。
  2. リストのアイテムを 追加で 取得できる 別の queryを定義します。
  3. 2で定義した 別の queryと 最初の queryから取得した適切なカーソルを使用して、次のページを取得します。このとき、必要なアイテムの数も指定します。
  4. そして、 最初の リストのアイテムに新しいアイテムをマージするコードを書いて、ビューを再レンダリングします

それではやってみましょう。ユーザーのプロフィールページのすべてのデータを取得する典型的な例です。

query ProfileQuery($userLogin: String!) {
  gitHub {
    user(login: $userLogin) {
      name
      avatarUrl
      email
      following {
        totalCount
      }
      followers(first: 5) {
        totalCount
        edges {
          node {
            id
            firstName
            lastName
            avatarUrl
          }
        }
      }
    }
  }
}

このqueryは、私たちが気になる2つのデータのグループを取り出します。

  1. 名前やメールアドレスなどのユーザーのプロフィール情報。
  2. それぞれいくつかのフィールドを持つフォロワーのリスト。まず最初の5人のフォロワーを取得します。

最初のqueryができたので、次の5人のフォロワーを取得するためにページネーションしてみましょう(人気のあるユーザーがそこにいるかも!)。

元のqueryを再利用しようとしても十分ではない

最初に気づくことは、ページネーションのために定義した最初のqueryを再利用すべきではないということです。私たちには新しいqueryが必要です。なぜなら…

  • ユーザーの全てのプロフィール情報を再取得したくありません。ユーザーのプロフィール情報はすでに取得しており、再度取得するのはコストがかかります。
  • 最初の5人のフォロワーだけから始めて、実際のページネーションに追加のロードを委任したいと思っています。なので最初のqueryにページネーションのための変数を追加することは冗長ですし、不要な複雑さが増してしまうように感じます。

そこで、新しいqueryを書いてみましょう。

query UserProfileFollowersPaginationQuery(
  $userLogin: String!
  $first: Int!
  $after: String
) {
  gitHub {
    user(login: $userLogin) {
      followers(first: $first, after: $after) {
        pageInfo {
          hasNextPage
          endCursor
        }
        edges {
          node {
            id
            firstName
            lastName
            avatarUrl
          }
        }
      }
    }
  }
}

これでいきましょう! これでページネーションに必要なものはすべて揃いました。素晴らしい。しかし、ここで注意すべきことがいくつかあります。

  • このqueryを手書きで書く必要があります。
  • フォロワーをページングしたいユーザーがわかっていても、変数を使ってその情報1を再度queryに与える必要があります。これは最初のqueryでどのようにユーザーを選択したかと正確に一致させる必要があり、間違えないようにする必要があります。
  • 手動で次のカーソルをqueryに指定してページネーションする必要があります。指定するカーソルはこのビューでは常に最後のカーソルになることが分かっていますが、手動でやる必要があります。

このような作業を手動でしなければならないのは残念なことです。もしフレームワークがページネーションのqueryを生成して、毎回同じことになるこれらのステップを処理できるとしたらどうでしょうか…?

そう、nodeインターフェイスとConnectionベースのページネーションを使えば、Relayはやってくれます!

Relayでのページネーション

ここでは、Relayでのページネーションの仕組みを先ほどの例と同様にシンプルなプロフィールページを使って説明しましょう。プロフィールページにはユーザに関する情報が掲載されており、ユーザの友達のリストも掲載されています。友達のリストはページネーションができるものにしておきましょう。

// Profile.ts
import * as React from 'react';
import { useLazyLoadQuery } from 'react-relay/hooks';
import { graphql } from 'react-relay';
import { ProfileQuery } from './__generated__/ProfileQuery.graphql';
import { FriendsList } from './FriendsList';

interface Props {
  userId: string;
}

export const Profile = ({ userId }: Props) => {
  const { userById } = useLazyLoadQuery<ProfileQuery>(
    graphql`
      query ProfileQuery($userId: ID!) {
        userById(id: $userId) {
          firstName
          lastName
          ...FriendsList_user
        }
      }
    `,
    {
      variables: { userId },
    },
  );

  if (!userById) {
    return null;
  }

  return (
    <div>
      <h1>
        {userById.firstName} {userById.lastName}
      </h1>
      <h2>Friends</h2>
      <FriendsList user={userById} />
    </div>
  );
};

これがプロフィールページを表示するためのルートのコンポーネントです。ご覧のように、これはqueryを作成して、このコンポーネント自身を表示するための情報(firstNamelastName)を要求し、そしてFriendsList_userのfragmentをqueryに含みます。fragmentにはFriendsListコンポーネントをレンダリングできるようにするために必要なUser型のデータを含んでいます。

コンポーネントの真のモジュール性の力

今のところどこにもページネーションは見当たりませんよね? 待ってください、すぐ来ますから。ただ、その前に気をつけてほしいことがあります。<FriendsList />がページネーションを行っていることをこのコンポーネントが知る必要はありません。これが Relay のもう一つの強みです。このことがもたらす意味をいくつか挙げてみましょう。

  • どんなコンポーネントでも、既にレンダリングしているコンポーネントを弄らずに、ページネーションを独立して導入できます。「そんなことか」と思いましたか? 2週間の急ぎのプロジェクトでなくても、ページネーションを導入する必要のあるコンポーネントがかなり多くの画面に分散されている場合、きっとそうは思わないでしょう。
  • ProfileQueryは変数のような不要なものを定義する必要はなく、<FriendsList />がページネーションできるようにするだけです。
  • 既に仄めかされていますが、これらの特徴はコンポーネント間に暗黙の(または明示的な)依存関係が作成されないことを意味します。つまり、何かを壊す危険を冒すことなく、コンポーネントを安全にリファクタリングしたりメンテナンスできます。高速に作業ができるということです。

ページネーションを行うコンポーネントの構築

下記はFriendsListコンポーネントで、これが実際にページネーションを行っているものです。こちらは少し濃い内容になります。

// FriendsList.ts
import * as React from 'react';
import { usePaginationFragment } from 'react-relay/hooks';
import { graphql } from 'react-relay';
import { FriendsList_user$key } from './__generated__/FriendsList_user_graphql';
import { FriendsListPaginationQuery } from './__generated__/FriendsListPaginationQuery_graphql';
import { getConnectionNodes } from './utils/getConnectionNodes';

interface Props {
  user: FriendsList_user$key;
}

export const FriendsList = ({ user }: Props) => {
  const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<FriendsListPaginationQuery, _>(
    graphql`
      fragment FriendsList_user on User
        @argumentDefinitions(first: { type: "Int!", defaultValue: 5 }, after: { type: "String" })
        @refetchable(queryName: "FriendsListPaginationQuery") {
        friends(first: $first, after: $after) @connection(key: "FriendsList_user_friends") {
          edges {
            node {
              id
              firstName
            }
          }
        }
      }
    `,
    user,
  );

  return (
    <div>
      {getConnectionNodes(data.friends).map((friend) => (
        <div key={friend.id}>
          <h2>{friend.firstName}</h2>
        </div>
      ))}
      {hasNext ? (
        <button disabled={isLoadingNext} onClick={() => loadNext(5)}>
          {isLoadingNext ? 'Loading...' : 'Load more'}
        </button>
      ) : null}
    </div>
  );
};

ここにはたくさんのことが書かれていて、これからすべてを少しずつ分解していきますが、まずはどれだけ手作業が少ないかに注目してください。その点について、いくつか見ていきましょう。

  • ページネーションに使用するカスタムqueryを定義する必要はありません。Relayが自動的に生成してくれます。
  • ページネーションで次のカーソルを追跡する必要はありません。Relayが自動的にやってくれるので、私たちがミスすることはありません。
  • ページネーション結果をストアにあるものとマージするためのカスタムロジックは不要です。Relayが代行してくれます。
  • ロードの状態を追跡したりロード可能なアイテムの数が増えたかどうかを確認したりするために、余計なことをする必要はありません。Relayが提供してくれるので、こちら側で追加のアクションをする必要はありません。

コードが少なくて済むというメリット以外にも、自分で書かなければならないコードが少なくなる、つまり何かをミスする可能性が少なくて済むというメリットもあります。

それでは上のコードの中で、それを可能にしているものを分解してみましょう。難しいものがいくつかありますね。

import { FriendsList_user$key } from './__generated__/FriendsList_user_graphql';
import { FriendsListPaginationQuery } from './__generated__/FriendsListPaginationQuery_graphql';

このコードでは、__generated__フォルダから型定義をインポートしています。これらは、定義したfragmentと、Relayが自動的に生成したページネーションのためのクエリの、両方の型安全を確保するためのものです。

import { getConnectionNodes } from './utils/getConnectionNodes';

getConnectionNodesという関数もインポートします。これはカスタムヘルパーで、任意のconnectionから内部にあるすべてのnodeを型安全な方法で配列に取り出すことができます。公式のRelayのパッケージにはありませんが自分で作るのはとても簡単で、ここで例を見ることができます。標準化されているからこそ簡単に作れるツールの良い例です。

  const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<FriendsListPaginationQuery, _>(
    graphql`
      fragment FriendsList_user on User
        @argumentDefinitions(first: { type: "Int!", defaultValue: 5 }, after: { type: "String" })
        @refetchable(queryName: "FriendsListPaginationQuery") {
        friends(first: $first, after: $after) @connection(key: "FriendsList_user_friends") {
          edges {
            node {
              id
              firstName
            }
          }
        }
      }
    `,
    user,
  );

usePaginationFragmentというhookを使って、propsのページネーションに関連する一部を返してくれます。またdataも返していますが、これは定義した FriendsList_userfragmentのデータです。

fragmentといえば、良いことが起きています。fragmentの定義で何が起こっているのか、もっと深く掘り下げてみましょう。

@argumentDefinitions(first: { type: "Int!", defaultValue: 5 }, after: { type: "String" })

Relayではfragmentの引数を定義することができる

まず目立っているのは、fragmentに@argumentDefinitionsというdirectiveを追加したことです。このdirectiveはfirstInt!型)とafterString型)という2つの引数を取ります。firstは必須なので、fragmentの引数に値を指定しない場合Relayは定義されているデフォルト値(この場合5)を使用します。

fragmentの引数を定義できることも、モジュール性とスケーラビリティに大きな違いをもたらすRelayの機能の1つです。これがどのように機能するのかについてここでは詳しく説明しませんが、FriendsList_userfragmentを使用しているユーザーは、そのfragmentを使用する際にfirstafterの値をオーバーライドすることができるようになります。以下のようになります。

query SomeUserQuery {
  loggedInUser {
    ...FriendsList_user @arguments(first: 10)
  }
}

これにより、デフォルトの最初の5人のフォロワーではなく、最初の10人のフォロワーを直接<FriendsList />に取得することができます。

Relayがページネーションqueryを作成する

@refetchable(queryName: "FriendsListPaginationQuery")

先ほどのコードの後には、@refetchableという別のdirectiveがあります。これは、新しい変数でfragmentのデータを再取得できるようにしたいということをRelayに伝えています。directiveに与えられたqueryNameは、生成されるqueryを呼び出すための名前にFriendsListPaginationQueryを使用することを示しています。

これにより、おおむね次のようなqueryが生成されます。

query FriendsListPaginationQuery($id: ID!, $first: Int!, $after: String!) {
  node(id: $id) {
    ... on User {
      friends(first: $first, after: $after) {
        pageInfo {
          endCursor
          hasNextPage
          startCursor
          hasPreviousPage
        }
        edges {
          node {
            id
            firstName
          }
          cursor
        }
      }
    }
  }
}

しかし、このqueryについて知ったり、考えたり、気にする必要はありません! queryに必要な変数(idafter、次へページネーションするためのカーソルなど)の供給など、すべての繋ぎ込みはRelayがやってくれます。あなたは取得したいアイテムの数を指定するだけです。

これこそが、Relayによるページネーションをとても人間工学的にしている部分です。Relayが文字通りあなたのためにコードとqueryを記述してくれるので、ページネーションの複雑さをすべて隠してくれます!

ConnectionをRelayに知らせれば、あとはRelayにおまかせ

        friends(first: $first, after: $after) @connection(key: "FriendsList_user_friends") {
          edges {
            node {
              id
              firstName
            }
          }
        }

**friends(first: $first, after: $after)** ここでフィールドを指定します。friendsはページネーションしたいconnectionを持つフィールドです。argumentDefinitionsで定義されているfirstafterを渡していることに注意してください。

**@connection** friendsには@connection(key: "FriendsList_user_friends") というdirectiveが付いています。このdirectiveは、ページネーションしたいconnectionの場所をRelayに伝えます。このdirectiveを追加することで、サーバに送信されるqueryの中で、connectionを指定した部分にpageInfoの全てのフィールドの指定を自動的に追加するなど、Relay はいくつかのことを行うことができるようになります。これによりRelayはその情報を利用してさらにロードできるかどうかを判断したり、適切なカーソルを自動的に使用してページネーションを行うことができます。このように間違ったことをしてしまう可能性のある手動のステップを削除し、自動化しているのです。

繰り返しになりますが、Relayがこれら全てを行ってくれるのであなたが何かを見たり考える必要はないものの、サーバーに送信されるfriendsの指定は次のようになります。

friends(first: $first, after: $after) {
  pageInfo {
    endCursor
    hasNextPage
    startCursor
    hasPreviousPage
  }
  egdes {
    node {
      ...
    }
    cursor
  }
}

@connectionアノテーションを追加することで、Relayはページネーションに必要な項目を追加するべき場所を知ることができます。

さらに@connectionが行うのは、キャッシュの更新でアイテムを追加したり削除したりするときなど、キャッシュ内でこのconnectionと対話する必要がある場合3に使用するキーをRelayに指定することです。複数のリストで同じconnectionを介してページネーションする場合があるため、ここで一意のキーを設定することは重要です。

これはまた、Relayがページネーションのレスポンスから取り出して現在のページネーションリストに追加する必要のある、すべての場所を推測できることを意味します。

        <button disabled={isLoadingNext} onClick={() => loadNext(5)}>

それ以外、Relayが提供するものを実際に使用するコードのほとんどはかなり自明のはずです。

これはどのように機能するのか?

つまり、ページネーションがどのように見えるかをまとめると、基本的にはfragmentの定義の中でdirectiveを使って必要な情報をRelayに渡し、その見返りとしてRelayができる限りのことを自動化してくれるということになります。

しかし、このようなことをRelayはどのようにして実現できるのでしょうか?

それはすべて規則と標準化に帰着します。グローバルな識別とnodeインターフェースの仕様に従えば、Relayは以下のことが可能です。

  • 現在いる特定のnodeを再取得するためのqueryを自動的に生成し、そのqueryに再取得中のfragmentを自動的に追加します。
  • 対象となるオブジェクトのidが特定のオブジェクトのみを示すことが分かっているため、生成されたクエリに変数を与える必要がないことを保証します2

また、Connectionのページネーション仕様に従うことで、Relayは以下のようなことができます。

  • 初期queryとなるProfileQueryと生成されたFriendsListPaginationQueryのqueryの両方に、必要なメタデータの指定を自動的に追加します。
  • データの構造が標準化されたconnectionであることを知っているので、ページネーションの結果を既存のリストに自動的にマージします。したがって、必要なものは全て取り出せます。
  • 標準化された方法でpageInfoで利用できるようになるため、より多くの結果をロードするために使用するカーソルを自動的に追跡します。pageInfoは、(上で述べたように)あなたが知らないところで自動的にクエリに挿入することができます。これも標準化されているからできることです。

そして、その結果は本当に素晴らしいものになっています。Relayは、ページネーションを人間工学的にしただけでなく、手作業でのエラーが発生する可能性をほぼすべて排除してくれました。

まとめ

この記事では、Relayのようなフレームワークがどれだけ自動化できるのか、また、規則に従えばDXがどれだけ素晴らしいものになるのか、ということにスポットを当ててみました。この記事では、以下の内容に光を当てました。

  • GraphQLでのページネーションは多くの手作業を必要とし、開発者をミスさせてしまう可能性があります。
  • 規則に従うことで、Relayのようなフレームワークはページネーションの開発体験を信じられないほど人間工学的なものに変えることができ、手作業によるエラーの原因となることのほとんど(すべてではないにしても)を取り除くことができます。

これは良い入門編ですが、Relayにはページネーションのための機能が他にもたくさんあります。詳細についてはRelayの公式ドキュメントをご覧ください。

最後までお読みいただきありがとうございました!


  1. 訳注:$userLoginのこと。ProfileQueryUserProfileFollowersPaginationQueryの両方に同じ値を指定する必要があります。

  2. 訳注:自動的に生成されたクエリに対して何らかの変数を与える必要はないということです。最初にqueryでアクセスできたオブジェクトのidを使用して、そのオブジェクト配下のリストを取得するqueryを実行できるので、複雑な変数は不要です。仮にidが一意ではなかったりnodeインターフェースがない場合、リストを所有するオブジェクトを単一で取得するqueryを自動生成する方法がありません。

  3. 訳注:対話する必要がある理由は、アイテムを追加したり削除するなら、connectionからも同様に追加したり削除する必要があるからです。そうしないとリストのコンポーネントはユーザーのアクション(追加や削除)で変化しません。