🍓

Apollo Client を基礎から理解する(QueryとMutationのhooksの使い方編)

2022/02/13に公開

現在のプロジェクトで Apollo Client を触り始めて約半年。いまだに「Apollo Client完全理解した!」 と言えるレベルに至っていないのですが、Apollo Client の最難関「キャッシュ」をはじめとした基本的な使い方についてまとめてみました!

間違っている内容やさらに良い方法等あれば、コメント欄にて(優しく)ご指摘いただけるととても嬉しいです。

GraphQLの基本

本題に入る前に、GraphQLの操作の種類をおさらいしておきます。

GraphQLの操作の種類

GraphQLの操作は3つあります。

  • Query:取得系
  • Mutation:更新系
  • Subscription:サーバプッシュ型のリアルタイム通信

Apollo Clientにはこれらの操作を行うための便利なhooksが用意されています。
本記事では、QueryとMutationについて記述します。

hooksの使い方

今回は、「アクセサリーを取り扱うECサイト」を題材に解説していきたいと思います。
以下のGraphQLスキーマを使っていきます!

使用するGraphQLスキーマ
# ルートオペレーション
type Query {
  "商品一覧"   
  items: [Item!]!
}
type Mutation {
  "商品登録"
  createItem(input: CreateItemInput!): Item!
  "商品編集"
  updateItem(input: UpdateItemInput!): Item!
  "商品削除"
  deleteItem(input: DeleteItemInput!): DeleteItemResult!
  "商品を一括で公開状態に変更"
  changeAllItemsPublic: [Item!]!
  "商品の並び順をシャッフルする"
  shuffleItemOrder: [Item!]!
}

# Type
"商品の型"
type Item {
  id: Int!
  name: String!
  price: Int!
  status: ItemStatusType!
}
"商品削除のレスポンス型"
type DeleteItemResult {
  id: Int!
}

# Enum
"公開設定の種類"
enum ItemStatusType {
  PUBLIC
  PRIVATE
}

# Input
"商品登録で使う引数の型"
input CreateItemInput {
  name: String!
  price: Int!
  status: ItemStatusType!
}
"商品編集で使う引数の型"
input UpdateItemInput {
  id: Int!
  name: String
  price: Int
  status: ItemStatusType
}
"商品削除で使う引数の型"
input DeleteItemInput {
  id: Int!
}

クエリ(Query)

クエリのhooksは2種類あります。
実行するタイミングに応じて、以下の適切な方を選んで実行します。

hooks 実行タイミング
useQuery Componentがrenderされたらクエリ実行
useLazyQuery 任意のイベントをトリガーにしてクエリ実行

例:useQuery(商品一覧取得処理)

//商品リストを取得するクエリ
const GET_ITEMS = gql`
  query GetItems {
    items {
      id
      name
      price
      status
    }
  }
`

// useQueryの実装を含むコンポーネントがレンダリングされた後に、処理を実行する
const { data, loading, error } = useQuery(GET_ITEMS)

例:useLazyQuery(商品一覧取得処理)

// 「getItems」が処理を実行する関数となります。
const [getItems, { data, loading, error }] = useLazyQuery(GET_ITEMS)

// 任意のイベント(ここではhandleClick関数)をトリガーにし、処理を実行します。
const handleClick = () => {
  getItems()
}

クエリ結果は自動的にキャッシュされる

Apollo Clientは、サーバーからクエリ結果を取得するたびに、以下のような正規化したデータを自動的にキャッシュします。これにより、同じクエリを実行した場合、非常に高速に取得ができるようになります。
Apollo Clientの「正規化の仕組み」についてはこちらの記事をご覧ください。

商品一覧クエリの結果
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
    name: 'イヤリング',
    price: 1000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
    status: "PRIVATE", 
  },
}

ミューテーション(Mutation)

データの書き込みはuseMutationというhooksで行います。

例:useMutaion(商品の新規登録処理)

// 商品登録のミューテーション
const CREATE_ITEM = gql`
  mutation CreateItem {
    createItem {
      id
      name
      price
      status
    }
  }
`

// 「createItem」が処理を実行する関数となります。任意のイベントをトリガーにし、処理を実行します。
const [createItem, { client, loading }] = useMutation(CREATE_ITEM)

// handleClick関数実行時に、「createItem」も実行する
const handleClick = () => {
  createItem({
    variables: { 
      input: {
        name: 'ピアス',
        price: 2000,
        status: "PUBLIC",
      }
    },
  })
}

ミューテーションもクエリ同様で自動的にキャッシュに保存されます。

商品登録ミューテーションの結果
{
  "ROOT_MUTATION": { 
    __typename: "Mutation", 
    "createItem({'input': {'name': 'リング', 'price': 10000, 'status': 'PUBLIC'}})": {
      "__ref": "Item:4"
    }
  },
  "Item:4": { 
    __typename: "Item", 
    id: 4, 
    name: 'リング', 
    price: 10000,
    status: "PRIVATE", 
  }
}

ミューテーションの結果はキャッシュされますが、すで保存されているクエリのキャッシュが更新されるかはまた別です。

例えば、商品削除後は商品一覧クエリのキャッシュからも該当商品が削除されるのが理想ですが、
この場合はキャッシュを自動更新してくれません。上記の商品登録の例も、自動更新されない例の一つです。

このようにミューテーションによって、クエリのキャッシュを自動更新するものと自動更新しないものがあるためこのポイントをしっかりとおさえておく必要があります。
(個人的に難関ポイントでした!)

ミューテーションによる自動更新の違い

前段の通り、ミューテーションには、クエリのキャッシュも一緒に自動更新するものと自動更新しないものがあります。高速な表示を提供し無駄な通信を減らするためにも、この違いを知ることはとても重要なので一緒に整理していきましょう。

クエリのキャッシュも一緒に自動更新される処理

自動更新される処理は以下の2つです。

  1. キャッシュ済みのエンティティを更新
  2. クエリ結果と同じデータセットを返す一括更新

1. キャッシュ済みのエンティティを更新

キャッシュ済みのエンティティに対して更新を行う場合は、自動でクエリのキャッシュも更新されます。

具体例(キャッシュ済みの商品を更新する)

【前提】

  • 商品一覧クエリは既にキャッシュに保存されている(Query items
  • 商品(Item:1)を更新する(Mutation updateItem
const [updateItem] = useMutation(UPDATE_ITEM)

return (
  <button onClick={() => updateItem({
    variables: { 
      input: {
        id: '1',
        name: 'リング',
	price: 10000,
      }
    },
  })}>商品更新</button>
)


上記のような更新処理後に、商品一覧クエリのキャッシュも自動で更新される仕組みです。

  1. 実行したミューテーション・引数・結果がキャッシュに保存される(ROOT_MUTATIONの箇所)
  2. 更新対象の商品も更新される(Item:1の正規化されたオブジェクトの箇所)
  3. ROOT_QUERYitemsはキャッシュIDしか格納していないので変更する必要なし
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },
+ "ROOT_MUTATION": { 
+   __typename: "Mutation", 
+   updateItem({input: {id: 1, name: "リング", price: 10000, status: "PUBLIC"}}): {
+     "__ref": "Item:1"
+   }
+ },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
-   name: 'イヤリング',
-   price: 1000,
+   name: 'リング',
+   price: 10000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
    status: "PRIVATE", 
  },
}

ROOT_QUERYにあるitemsクエリ自体のキャッシュは何も変更されていないのですが、
正規化の仕組みにより、商品一覧のキャッシュも自動的に更新されます。

2. クエリ結果と同じデータセットを返す一括更新処理

クエリ結果と同じ結果を返すミューテーションの場合も、自動的にクエリのキャッシュも更新を行います。

具体例(一括で商品を公開状態にする)

【前提】

  • 商品一覧クエリは既にキャッシュに保存されている(Query items
  • 一括で商品を公開状態に更新する(Mutation changeAllItemsPublic)
  • changeAllItemsPublicは商品一覧クエリと同じデータセットを返す
const [changeAllItemsPublic] = useMutation(CHANGE_ALL_ITEMS_PUBLIC)

return (
  <button onClick={() => changeAllItemsPublic()}>商品一括で公開状態にする</button>
)



上記のような一括更新処理後に、商品一覧クエリのキャッシュも自動で更新される仕組みです。

  1. 実行したミューテーション・引数・結果がキャッシュに保存される(ROOT_MUTATIONの箇所)
  2. 正規化した商品を更新する(Item:1、2、3の正規化したオブジェクトの箇所)
  3. ROOT_QUERYitemschangeAllItemsPublicの値は同じデータセットであるため、必然とitemsが更新されたことになる
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },
+ "ROOT_MUTATION": { 
+   __typename: "Mutation", 
+   changeAllItemsPublic: [
+     { "__ref": "Item:1" },
+     { "__ref": "Item:2" },
+     { "__ref": "Item:3" }
+   ]
+ },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
    name: 'イヤリング', 
    price: 1000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
+   status: "PUBLIC", 
  },
}

先程のエンティティを更新する例とほとんど変わらないですね。
ROOT_QUERYにあるitemsクエリ自体のキャッシュは何も変更されていないのですが、
正規化の仕組みにより、商品一覧のキャッシュも自動的に更新されます。

クエリのキャッシュが自動更新されない処理

自動更新されない処理は以下の4つです。

  1. 追加処理
  2. 削除処理
  3. 並び替え処理
  4. レスポンスとは関係ないデータの更新処理
    ・ ログアウト処理
    ・ ローカルデータ更新

1. 追加処理

新しいアイテムを追加処理は、クエリのキャッシュは自動更新されません。

具体例(商品追加)

【前提】

  • 商品一覧クエリは既にキャッシュに保存されている(Query items
  • 商品(Item:4)を追加する(Mutation createItem)
const [createItem] = useMutation(CREATE_ITEM)

return (
  <button onClick={() => createItem({
    variables: { 
      input: {
        name: 'リング',
	price: 10000,
	status: PUBLIC
      }
    },
  })}>商品更新</button>
)



上記のような追加処理後に、商品一覧のキャッシュが自動更新されない仕組みです。

  1. 実行したミューテーション・引数・結果がキャッシュに保存される(ROOT_MUTATIONの箇所)
  2. 新しく追加した商品が正規化され、キャッシュに保存される(Item:4のオブジェクトの箇所)
  3. ROOT_QUERYitemsの配列には自動で追加されない
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },
+ "ROOT_MUTATION": { 
+   __typename: "Mutation", 
+   "createItem({'input': {'name': 'リング', 'price': 10000, 'status': 'PRIVATE'}})": {
+     "__ref": "Item:4"
+   }
+ },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
    name: 'イヤリング', 
    price: 1000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
    status: "PUBLIC", 
  },
+ "Item:4": { 
+   __typename: "Item", 
+   id: 4, 
+   name: 'リング', 
+   price: 10000,
+   status: "PRIVATE", 
+ }
}

2. 削除処理

削除処理も同様に、クエリのキャッシュは自動更新されません。

具体例(商品削除)

【前提】

  • 商品一覧クエリは既にキャッシュに保存されている(Query items
  • 商品(Item:3)を削除する(Mutation deleteItem)
const [deleteItem] = useMutation(DELETE_ITEM)

return (
  <button onClick={() => deleteItem({
    variables: { 
      input: {
        id: 3,
      }
    },
  })}>商品削除</button>
)



上記のような削除処理後に、商品一覧のキャッシュが自動更新されない仕組みです。

  1. 実行したミューテーション・引数・結果がキャッシュに保存される(ROOT_MUTATIONの箇所)
  2. 今回deleteItemの結果はidのみしか受け取っていないため、正規化したデータも変更しない
  3. ROOT_QUERYitemsの配列は自動で削除されない
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },
+ "ROOT_MUTATION": { 
+   __typename: "Mutation", 
+   "deleteItem({'input': {'id': 3})": {
+     "__ref": "Item:3"
+   }
+ },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
    name: 'イヤリング', 
    price: 1000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
    status: "PUBLIC", 
  },
}

3. 並び替え処理

並び替え処理も同様に、クエリのキャッシュは自動更新されません。

具体例(商品の並び替え)

【前提】

  • 商品一覧クエリは既にキャッシュに保存されている(Query items)
  • ランダムに並び替えをし、商品一覧を配列で返す(Mutation shuffleItemOrder)
const [shuffleItemOrder] = useMutation(SHUFFLE_ITEM_ORDER)

return (
  <button onClick={() => shuffleItemOrder()}>商品の並び順をシャッフルする</button>
)



上記のような並び替え処理後に、商品一覧のキャッシュが自動更新されない仕組みです。

  1. 実行したミューテーション・引数・結果がキャッシュに保存される(ROOT_MUTATIONの箇所)
  2. ROOT_QUERYitemsROOT_MUTATIONshuffleItemOrderの値は異なる
  3. ROOT_QUERYitems自体は自動で並び替えされない
{
  "ROOT_QUERY": { 
    __typename: "Query", 
    items: [
      { "__ref": "Item:1" },
      { "__ref": "Item:2" },
      { "__ref": "Item:3" }
    ]
  },

+ "ROOT_MUTATION": { 
+   __typename: "Mutation", 
+   "shuffleItemOrder: [
+     { "__ref": "Item:3" }
+     { "__ref": "Item:2" },
+     { "__ref": "Item:1" },
+   ]
+ },
  "Item:1": { 
    __typename: "Item", 
    id: 1, 
    name: 'イヤリング', 
    price: 1000,
    status: "PUBLIC",
  },
  "Item:2": { 
    __typename: "Item", 
    id: 2, 
    name: 'ピアス', 
    price: 2000,
    status: "PUBLIC", 
  },
  "Item:3": { 
    __typename: "Item", 
    id: 3, 
    name: 'ネックレス', 
    price: 3000,
    status: "PUBLIC", 
  },
}

4. レスポンスとは関係ないデータを更新処理

ログアウト

ログアウト後は、ログイン後のみ取得できるデータをキャッシュから削除する必要があるかと思います。もしも、削除しなければ、同じブラウザで別のユーザーでログインした場合に、前回ログインしたユーザー情報を取得してしまうからです。

キャッシュをリセットするには、以下のように行います。

const [logout, { client }] = useMutation(LOGOUT, {
  update () {
    // ミューテーション完了後にキャッシュをクリアにする
    client.clearStore()
  }
})

ローカルデータの更新

何らかの処理後にリアクティブ変数やtypePoliciesなどのローカルデータも同様に自動更新は行ってくれません。

以上、キャッシュを自動更新する処理と自動更新されない処理を確認することができました。

キャッシュの更新方法

追加処理や削除処理も一度の処理でクエリのキャッシュも更新したい・・・ですよね。
ミューテーション成功後にキャッシュを更新する方法には、大きく分けて以下の2種類があります。

  • 直接キャッシュを書き換える方法
  • キャッシュを再取得する方法

直接キャッシュを書き換える(ネットワーク通信なし)

直接キャッシュを書き換えるため、クエリを再取得するための通信を減らすことができます。

toReferenceを使った書き方
const [createItem] = useMutation(CREATE_ITEM, {
  update (cache, { data: { createItem } }) {
    // 追加した商品のキャッシュIDを取得
    const cacheId = cache.identify(createItem)
    console.log(cacheId) // Item:4

    cache.modify({
      fields: {
        items(existingItemRefs, { toReference }) {
          console.log(existingItemRefs) // [{__ref: 'Item:1'}, {__ref: 'Item:2'}, {__ref: 'Item:3'}]
          console.log(toReference(cacheId)) // {__ref: 'Item:4'}
          return [toReference(cacheId), existingItemRefs]
        },
      },
    })
  }
})



ポイント
modify:キャッシュを更新する関数。filedにはキャッシュを変更したいルートフィールドを指定

cache.modify({
  fields: {
    // 第一引数で指定したルートフィールドのキャッシュデータを取得する
    // 第二引数には使用するヘルパ関数をオブジェクトで指定する
    items(existingItemRefs, { toReference })
  }
})



toReference:参照オブジェクトを取得する関数

// { __ref: キャッシュID } ← このようなオブジェクトを取得します
toReference(cacheId)
writeFragmentを使った書き方
const [createItem] = useMutation(CREATE_ITEM, {
  update (cache, { data: { createItem } }) {
    cache.modify({
      fields: {
        items(existingItemRefs = []) {
          const newItemRef = cache.writeFragment{
            data: createItem,
            query: gql`
              fragment ItemField on Item {
                id
                name
                price
                status
              }	
            `
          });
          console.log(existingItemRefs) 
	  // [{__ref: 'Item:1'}, {__ref: 'Item:2'}, {__ref: 'Item:3'}]
          console.log(newItemRef) 
	  // {__ref: 'Item:4'}
          // 新しく作成した商品のキャッシュIDを商品リストのキャッシュID配列に追加する
          return [...existingItemRefs, newItemRef];
        }
      }
    })
  })

キャッシュを再取得する(ネットワーク通信あり)

refetchQueriesを使うことで、ミューテーション完了後に再取得を行います。
こちらはネットワーク通信がある処理となります。

const [createItem] = useMutation(CREATE_ITEM, {
  // ミューテーション完了後に再取得する。クエリ名を指定します。
  refetchQueries: ['GetItems'],
});

まとめ

以上、QueryとMutationのhooksの使い方についてまとめてみました。
かなり冗長になってしまってすみませんm(..)m
この記事が何かお役立ていただけましたら幸いです。

Discussion