👀

Apollo Relay-style cursor paginationを体系的にまとめた(つもり)

2021/06/13に公開

Apolloの、Relay-style cursor paginationについてまとめてみました。
表題の通り、つもりなのでおかしい点がありましたらご指摘して頂きたいです。

ちなみに、今回はGitHubのGraphQL APIを使用します。

作成したデモアプリは以下のURLにあります。
https://github.com/kupuma-ru21/apollo_relay_style_cursor_pagination


環境構築

まず、アプリ作成から始めます。

npx create-react-app apollo_relay_style_cursor_pagination --template typescript

完了したら不要なファイルを削除します。
src配下で、以下のコマンドを打ってください。

rm -rf App.css App.test.tsx reportWebVitals.ts logo.svg setupTests.ts

削除したら、以下のようにファイルを修正します。

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
src/App.tsx
const App = () => {
  return <div>Hello, apollo_relay_style_cursor_pagination</div>;
};

export default App;

これで、アプリ作成の雛形作成は完了です。


githubでtokenを生成し、アプリに設定する

https://github.com/settings/tokens/new にアクセスし、tokenを生成します。
生成されたtokenをどこかに控えておいてください。
※ tokenは他者に公開しないようにしてください

アプリのディレクトリ直下に.env.development.localを作成します。
.env.development.localは以下のように記載してください。

REACT_APP_GITHUB_TOKEN=生成したtoken

パッケージをinstall

https://www.apollographql.com/docs/react/get-started/ を参考に、
パッケージをinstallします。
今回は、yarnを使います。

yarn add @apollo/client graphql

Apolloクライアントをセットアップ

src直下にclient.tsを作成します。

client.ts
import {
  ApolloClient,
  InMemoryCache,
  ApolloLink,
  HttpLink,
} from '@apollo/client';

const GITHUB_TOKEN = process.env.REACT_APP_GITHUB_TOKEN;

const headersLink = new ApolloLink((operation, forward) => {
  operation.setContext({
    headers: { authorization: `Bearer ${GITHUB_TOKEN}` },
  });
  return forward(operation);
});
const endPoint = 'https://api.github.com/graphql';
const httpLink = new HttpLink({ uri: endPoint });
const link = ApolloLink.from([headersLink, httpLink]);

export const client = new ApolloClient({ link, cache: new InMemoryCache() });

また、src/index.tsxを修正します。

src/index.tsx
import ReactDOM from 'react-dom';
import { ApolloProvider } from '@apollo/client';
import { client } from './client';
import './index.css';
import App from './App';

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
);

<App><ApolloProviderでラップするよう修正しました。


queryを作成

src/graphql/index.tsを新たに作成します。

src/graphql/index.ts
import { gql } from '@apollo/client';

export const REPOSITORIES = gql`
  query Repositories($query: String!) {
    search(query: $query, type: REPOSITORY) {
      repositoryCount
    }
  }
`;

query Repositoriesは引数$queryに対応する、リポジトリのカウントを返します。

また、App.tsxを修正します。

src/App.tsx
import { useQuery } from '@apollo/client';
import { REPOSITORIES } from './graphql';

const App = () => {
  const { data, loading, error } = useQuery(REPOSITORIES, {
    variables: { query: 'front_end_developer' },
  });
  console.log(data);
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  return <div>Hello, apollo_relay_style_cursor_pagination</div>;
};

export default App;

App.tsxでしてることとして、

  1. query Repositoriesをimport
  2. useQueryの引数にquery Repositoriesを設定し、さらにvariablesを定義
  3. { loading, error }を使用し描画を制御

これで、とりあえずリポジトリ件数を取得するとこまでは完成しました。
前置き長くなりました。。。次からPagenationの話になります。


Pagenationを行うための設定

src/graphql/index.tsを修正します。

src/graphql/index.ts
import { gql } from '@apollo/client';

export const REPOSITORIES = gql`
  query Repositories(
    $first: Int
    $after: String
    $last: Int
    $before: String
    $query: String!
  ) {
    search(
      first: $first
      after: $after
      last: $last
      before: $before
      query: $query
      type: REPOSITORY
    ) {
      repositoryCount
      # リポジトリ情報
      edges {
        cursor
        node {
          ... on Repository {
            name
          }
        }
      }
      # リポジトリ情報を表示してるページの情報
      pageInfo {
        endCursor
        hasNextPage
        startCursor
        hasPreviousPage
      }
    }
  }
`;

修正内容としては、

  1. pagenationに必要な引数$first, $after, $last, $beforeを追加
  2. pagenationに必要なfieldedges, pageInfoを追加

また、App.tsxを修正します。

App.tsx
import { useQuery } from '@apollo/client';
import { REPOSITORIES } from './graphql';
const VARIABLES = {
  first: 5,
  after: null,
  last: null,
  before: null,
  query: 'フロントエンドエンジニア',
};

const App = () => {
  const { data, loading, error } = useQuery(REPOSITORIES, {
    variables: VARIABLES,
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  const repositoriesInfo = data.search.edges;
  return (
    <ul>
      {repositoriesInfo.map((repository: any) => {
        const { name } = repository.node;
        return <li key={name}>{name}</li>;
      })}
    </ul>
  );
};

export default App;

修正内容として、query Repositoriesの引数を追加したので、それに対応するようVARIABLESを修正してます。
VARIABLESのプロパティfirst: 5で、初期表示にリポジトリ情報を先頭から5件、取得するよう指定してます。


pagenationを実装

  • 次のページを表示する実装

まず、App.tsxを修正します。

App.tsx
import { useQuery } from '@apollo/client';
import { useCallback } from 'react';
import { REPOSITORIES } from './graphql';
export const PER_PAGE = 5;
export const VARIABLES = {
  first: 5,
  after: null,
  last: null,
  before: null,
  query: 'フロントエンドエンジニア',
};

const App = () => {
  const { data, loading, error, fetchMore } = useQuery(REPOSITORIES, {
    variables: VARIABLES,
  });
  const nextPage = useCallback(
    (pageInfo) => {
      fetchMore({
        variables: {
          first: 5,
	  after: pageInfo.endCursor,
	  last: null,
          before: null,
        },
      });
    },
    [fetchMore]
  );

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  const repositoriesInfo = data.search.edges;
  const pageInfo = data.search.pageInfo;
  return (
    <div>
      <ul>
        {repositoriesInfo.map((repository: any) => {
          const { name } = repository.node;
          return <li key={name}>{name}</li>;
        })}
      </ul>
      <button onClick={() => nextPage(pageInfo)}>nextPage</button>
    </div>
  );
};

export default App;

修正内容としては、

  1. nextPageボタンを作成
  2. nextPage関数を作成し、引数にpageInfoを渡す。

また、nextPage関数の具体的な処理内容として
fetchMoreの引数に、次のページを表示するためのvariablesを指定します。
次のページを表示するためには、

  • pageInfoendCursorvariablesafterに設定
  • variablesfirstに表示したい件数を設定
  • last, beforeにはnullを設定。

さらにclient.tsも修正します。

client.ts
import {
  ApolloClient,
  InMemoryCache,
  ApolloLink,
  HttpLink,
} from '@apollo/client';

const GITHUB_TOKEN = process.env.REACT_APP_GITHUB_TOKEN;

const headersLink = new ApolloLink((operation, forward) => {
  operation.setContext({
    headers: { authorization: `Bearer ${GITHUB_TOKEN}` },
  });
  return forward(operation);
});
const endPoint = 'https://api.github.com/graphql';
const httpLink = new HttpLink({ uri: endPoint });
const link = ApolloLink.from([headersLink, httpLink]);

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        search: {
          merge(existing, incoming) {
            return { ...existing, ...incoming };
          },
          read(existing) {
            return existing;
          },
        },
      },
    },
  },
});

export const client = new ApolloClient({ link, cache });

const cacheを新たに作成し、fetchMoreが発生されたら
cacheを書き換えるようしてます。
fields配下のプロパティのkeyにsearchを記載してますが
これは、cacheを書き換えたいquery直下のfield名を記載します。
参考 (https://www.apollographql.com/docs/react/pagination/cursor-based/)

cacheを書き換えず、fetchMoreの第2引数のupdateQueryでqueryの更新するという方法もあるのですが、updateQueryはApolloの次のメジャーアップデートで削除予定の機能なので
使用するべきではないです。

  • 前のページを表示する実装
App.tsx
import { useQuery } from '@apollo/client';
import { useCallback } from 'react';
import { REPOSITORIES } from './graphql';
export const PER_PAGE = 5;
export const VARIABLES = {
  first: 5,
  after: null,
  last: null,
  before: null,
  query: 'フロントエンドエンジニア',
};

const App = () => {
  const { data, loading, error, fetchMore } = useQuery(REPOSITORIES, {
    variables: VARIABLES,
  });
  const nextPage = useCallback(
    (pageInfo) => {
      fetchMore({
        variables: {
          first: 5,
          after: pageInfo.endCursor,
          last: null,
          before: null,
        },
      });
    },
    [fetchMore]
  );
  const prevPage = useCallback(
    (pageInfo) => {
      fetchMore({
        variables: {
          first: null,
          after: null,
          last: 5,
          before: pageInfo.startCursor,
        },
      });
    },
    [fetchMore]
  );

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;
  const repositoriesInfo = data.search.edges;
  const pageInfo = data.search.pageInfo;
  return (
    <div>
      <ul>
        {repositoriesInfo.map((repository: any) => {
          const { name } = repository.node;
          return <li key={name}>{name}</li>;
        })}
      </ul>
      <button onClick={() => prevPage(pageInfo)}>prevPage</button>
      <button onClick={() => nextPage(pageInfo)}>nextPage</button>
    </div>
  );
};

export default App;

次のページを表示する実装とほぼ同じです。
前のページを表示するためには、

  • pageInfostartCursorvariablesbeforeに設定
  • variableslastに表示したい件数を設定
  • first, afterにはnullを設定。

pagenationの実装は大体おわりです。

あとは、ページボタンの表示制御を行います。
pageInfoにはhasNextPage, hasPreviousPageというプロパティが存在します。
hasNextPageは、次のページがあるか否かをbooleanで返します。
hasPreviousPageは、前のページがあるか否かをbooleanで返します。
ですので、App.tsxのボタン描画箇所を以下のように修正します。

{pageInfo.hasPreviousPage && (
  <button onClick={() => prevPage(pageInfo)}>prevPage</button>
)}
{pageInfo.hasNextPage && (
  <button onClick={() => nextPage(pageInfo)}>nextPage</button>
)}

これでpagenationの実装はおわりです。


fetchMoreの注意点

fetchMoreを使うとloadingが適切に更新されなくなります。
参考(https://github.com/apollographql/apollo-client/issues/1617)

なので、loadingが適宜、適切に更新されるよう修正する必要があります。
App.tsxuseQuery使用箇所を以下のように修正します。

const { data, loading, error, fetchMore } = useQuery(REPOSITORIES, {
  variables: VARIABLES,
  notifyOnNetworkStatusChange: true,
});

プロパティnotifyOnNetworkStatusChange: trueを追加することで
fetchMoreを使用時loadingが適宜、適切に更新されるようになります。


まとめ

Apolloの、Relay-style cursor paginationは、日本語の参考文献が少なく感じたので、実装に時間がかかったなあ。。。。

おわり。

Discussion