LoginSignup
7
5

More than 1 year has passed since last update.

Apollo v3のページネーションについて

Last updated at Posted at 2021-09-11

公式ドキュメント

何についての記事か?

Aoikki v3でのページネーション(特に、カーソルページネーション)についての記事になります。
主に、公式ドキュメントだとわかりにくかった部分の補足を書きます。
基本的な部分については公式ドキュメントを参考にしてください。

v3と前バージョンの大きな違いは何か?

updateQueryがdeprecatedになり、Cache APIにマージの処理を書くようになったことです。
より効率的にキャッシュを使うための施策のようです。

どのように書くか?(具体例)

クエリ実行の時、今までは updateQuery でマージの処理を書いていましたが、その記述は不要になります。
代わりに、CacheAPIでマージ処理を書きます。

export const useFeedsQuery = (variables: FeedsQueryVariables) => {
  // クエリの実行
  const { data, fetchMore, loading } = useQuery(FEED_QUERY, {
    variables: {
      id: "XXX"
      collectionInput: {
        limit: 10,
        cursor: "YYY",
      }
    },
   fetchPolicy: 'cache-and-network',
   nextFetchPolicy: 'cache-first',
    notifyOnNetworkStatusChange: true
  });

 // 次ページ取得関数
  const handleMore = useCallback(() => {
    fetchMore({
      variables: {
        collectionInput: {
          ...variables.collectionInput,
          cursor: data?.feeds?.pageInfo.endCursor,
        },
      },
    });
  }, [variables.collectionInput, data?.feeds?.pageInfo.endCursor, fetchMore]);

  return {
    feeds: data?.feeds?.edges || [],
    hasMore: data?.feeds?.pageInfo.hasNextPage || false,
    handleMore,
    loading,
  };
};
// CacheAPIのマージ部分
type FeedQueryResult = Pick<FeedQuery['feed'], 'pageInfo' | 'edges'>;
type QueryResultItem = { id: string };

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feeds: {
          keyArgs: ['id'],
          merge(
            existing: FeedQueryResult,
            incoming: FeedQueryResult,
            { args, readField }
          ): FeedQueryResult {
            const merged = {
              pageInfo: incoming.pageInfo,
              edges: [...(existing?.edges ?? [])],
            };

            let offset = offsetFromCursor(merged.edges, args?.collectionInput?.cursor, readField);
            if (offset < 0) offset = merged.edges.length;

            merged.edges = [...merged.edges.slice(0, offset), ...incoming?.edges];
            return merged;
          },
        }
      }
    }
  }
})

const offsetFromCursor = (items: QueryResultItem[], cursor: string, readField: ReadFieldFunction): number => {
  if (!cursor) return 0;

  for (let i = items.length - 1; i >= 0; --i) {
    const item = items[i];
    const id = readField('id', item);
    if (id === cursor) {
      return i + 1;
    }
  }

  return -1;
};

上記の書き方について以降で解説します。
ここで解説していない基本的な部分などについては、公式ドキュメントを参考にしてください。

具体例の解説(useQuery)

fetchPolicy

useQueryを実行したとき、データをどのように返すかを制御するものです。
次の種類があります。

fetchPolicy 説明
cache-first(デフォルトの動作) キャッシュがあればそれを返す、なければサーバーから取ってくる。
cache-and-network キャッシュがあればそれを返すが、同時にサーバーから取ってきてキャッシュを更新する。
network-only キャッシュは利用せず、サーバーから取ってきたものを返す(この場合もキャッシュは更新される)。
no-cache network-onlyのキャッシュを更新しない版。
cache-only cache-firstのサーバーからとってこない版(キャッシュになければ、エラーになる)。
standby

nextFetchPolicy

指定できるものはfetchPolicyと同じです。
useQueryを実行した後、ネットワーク状態の更新などでコンポーネントも更新されることがあり、useQueryも複数回実行される可能性がありますが、この時の振る舞いを決めるものとなります(おそらく)。
何も設定しないと、fetchPolicyと同じ値になります。

notifyOnNetworkStatusChange

これは通信状況の監視を行うかのフラグで、trueにしないとloadingは常にfalseになります。

具体例の解説(fetchMore)

variables

ここでのvariablesは変更になったものだけを書いてやれば良いです。
useQueryに渡したものとマージされます。
ただし、マージされるのは直下のみで、ネストしている場合は自分でマージする必要があります。

具体例の解説(CacheAPI)

typePolicies

GraphQLクエリタイプの_typenameの値がtype名やfield名になります。
例えば上記の具体例だと、以下のようなクエリタイプがある想定です。

// GraphQLクエリタイプ
export type FeedsQuery = { __typename?: 'Query' } & {
  feeds: { __typename?: 'FeedPageInfo' } & {
    pageInfo: { __typename?: 'CollectionPageInfo' } & Pick<
      Types.CollectionPageInfo,
      'startCursor' | 'endCursor' | 'hasNextPage'
    >;
    edges: Array<{ __typename?: 'Feed' } & FeedsFragment>;
  };
};

上記の具体例は次のように分割することも可能です(この分割の仕方はあまり良くないですが)。
詳細は、こちらを確認してください。

export const newCache = (): InMemoryCache => {
  return new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          feeds: {
            keyArgs: false,
            merge: true,
          },
        },
      },
      TopicPageInfo: {
        fields: {
          pageInfo: {
            merge(existing: CollectionInput, incoming: CollectionInput) {
              ...
            },
          },
          edges: {
            merge(existing: Array<FeedFragment>, incoming: Array<FeedFragment>) {
              ...
            },
          },
        },
      },
    }
  });
};

分割する場合は単一要素(サービス内でユニークな要素)のみにした方が良さそうです。
上の例のように複数要素が入るようなfieldを分割すると、予期しないところからキャッシュが更新されてしまう可能性があります。

keyArgs

args内のどのフィールド単位でキャッシュを分けるかを指定できます(複数指定可能)。
これを使わず、merge内でargsのフィールドを元にしたkeyを作りmapで管理する方法もあります(この場合、取り出す時にクエリのレスポンスの形にする必要があるのでreadの処理を書く必要があります)。
ただ、責任を分けるためにもkeyArgsを使うのが無難です。

// キャッシュを自分で分割する方法の例(具体例とは対応していません)
feeds: {
  read(existing = {}, { args: { groupId, offset, limit }}) {
    return existing[groupId] &&
      existing[groupId].slice(offset, offset + limit);
  },

  merge(existing = {}, incoming, { args: { groupId }}) {
    const merged = existing[groupId] ? existing[groupId].slice(0) : [];
    for (let i = 0; i < incoming.length; ++i) {
      merged[offset + i] = incoming[i];
    }
    existing[groupId] = merged;
    return existing;
  },
},

merge

キャッシュのデータと新しくとってきたデータをどのようにマージするか指定します。
第三引数にどういう条件でクエリが呼ばれたかを示すargs、readFieldやobjectMergeといったヘルパー関数が含まれます。
次のように書くだけだとうまくいかないケースも多いので気をつけてください。

return [...existing, ...incoming];

read

具体例のように何も指定がない時はmergeの返り値をそのまま返します。
取り出すときに何か手を加えたいときはこれに処理を書きます(例えば、一部だけ返したいときなど)。

トラブルシューティング

loadingを使った処理がうまく動かない

[原因: notifyOnNetworkStatusChange: trueの設定がない]
notifyOnNetworkStatusChange: true が useQueryのオプションに設定されていない可能性があります。

次ページを取得しようとすると無限ループになる

[原因: nextFetchPolicyがnetwork系になっている]
nextFetchPolicyがnetwork系になっていると、例えば次のようなことが発生します。
前提: 画面下に来ると次ページを取得する
1. fetchMoreを実行して次のページを取得する
2. キャッシュのリストが更新されるタイミングで再レンダリング、useQueryも実行されるが、その時もう一度最初のリクエスト(cursorなし)が飛ぶ
3. 1のレスポンスでリストを上書きするようになっている場合、画面下の状態になるので、再び1を実行する。
対応:
nextFetchPolicyをcache-firstにする。
こうすると、loading状態が切り替わったタイミングでも、キャッシュ(最初のリクエスト実行後、結果が追加されたリスト)が返るようになり、画面下にならなくなるので無限ループは解消されます。
fetchPolicyもcache-firstにすると、リスト内容が更新されないので注意してください(fetchPolicyは、network-onlycache-and-network が良いと思います)。

mergeの第三引数のargsが取れない

[原因: argsはuseQueryで直接実行したクエリタイプに対応するtypePolicyのmergeにしか値は入らない]
fieldを分割していた場合、そちらのmerge(例えば、前述のTopicPageInfo内のmerge)のargsは常にnullになります。

データが重複して取れてしまう

[原因: fetchPolicyにnetwork系を指定し、mergeでいつもexistingにincomingを追加している]
fetchPolicyにnetwork系を指定していると、コンポーネントのマウント(ページ遷移時など)の際、キャッシュがあってもuseQueryに渡した条件でリクエストが飛ぶので、新しく取ってくるデータは最初のページになります。
mergeの処理がexistingにincomingを追加する、というような処理になっていると、最初のページが二重でキャッシュに乗ってしまいます。
対応:
argsのcursor(idなど)と同じ値を持つ要素があった場合、それ以降の要素は一旦消してからincomingを追加する。

ページ遷移をしてもリストが更新されない

[原因1: mergeでいつもexistingにincomingを追加するようになっている]
こうすると、古いデータが上書きされずに残ってしまう。
対応:
特定の条件下ではexistingをincomingで上書きする。

[原因2: fetchPolicyがcache-firstになっている]
cache-firstだとキャッシュがあればいつもそれを使うので、新しいデータがいつまで経っても取得されない。
対応:
fetchPolicyをnetwork系にして、必要であればnextFetchPolicyをcache-firstにする。

Q&A

useQueryとfetchMoreの違いは?

useQueryはfetchPolicyに応じて値を返すのに対して、fetchMoreはリクエストを送り、その結果でキャッシュを更新する。

fetchPolicyとnextFetchPolicyの違いは?

fetchPolicyはコンポーネントがマウントされて最初のuseQuery呼び出しで有効なのに対して、nextFetchPolicyは再レンダリングの時などにuseQueryが呼び出される時に有効となる(おそらく)。

mergeの第三引数のargsとvariablesの違いは?

argsには実行したGraphQLクエリの引数が入ってきます。
フィールド名もクエリの引数名と同じになるので、variablesのフィールド名とは異なることに注意してください。

query Feeds(
  $feedsCollectionInput: CollectionInput!
  $groupId: String!
) {
  feeds(id: $groupId, collectionInput: $feedsCollectionInput) {
    pageInfo {
      startCursor
      endCursor
      hasNextPage
    }
    edges {
      ...Feed
    }
  }
}

例えば、上のようなクエリだとargsは下のようになります。

// args
{
  id: "XXX",
  collectionInput: {
    ...
  }
}

variablesは以下のようになるので、違いがわかると思います。

{
  feedsCollectionInput: {
    ...
  },
  otherCollectionInput: {
    ...
  },
  groupId: "XXX"
}

キャッシュはどのような形で保存されるか?

データが取得されると、正規化してキャッシュに保存されますが、もしすでにキャッシュされている場合はマージされます。
一番小さく分割した時のオブジェクトは、__typename:idをkeyとして保存されますし、もしidフィールドがなければ、keyFieldsによって指定することもできます。
クエリのキャッシュがあることで、順番を保証することが可能になっています。
mergeなどで受け取るデータは参照を含んでいますが、readFieldによって値を取り出すことが可能です。

// キャッシュの保存例
{
  "ROOT_QUERY": {
    "__typename": "Query",
    "tasks": [{ "__ref": "Task:1" }, { "__ref": "Task:2" }]
  },
  " Task:1": {
    "id": 1,
    "__typename": "Task",
    "title": "タスク1",
    "content": "タスク1の内容です"
  },
  " Task:2": {
    "id": 2,
    "__typename": "Task",
    "title": "タスク2",
    "content": "タスク2の内容です"
  }
}

参考:
- https://zenn.dev/kazu777/articles/b64935ea7d6fee
- https://www.apollographql.com/blog/apollo-client/caching/demystifying-cache-normalization/

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5