tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

React Apollo のキャッシュ機構とアクセス

f:id:tomoima525:20190722044223p:plain

GraphQL におけるトランザクション処理は Mutation と呼ばれます。トランザクション処理の結果に基づいてビューも更新するのが一般的ですが、GraphQL の場合、いくつかの手法があります。それらの手法、どれを採用すべきかについては、後続の記事にて解説するとして、今回は前提知識となる キャッシュ機構のふるまいと利用方法について書きます。なお、この記事は React Apollo(v2)の仕様に基づいています。

Apollo のキャッシュ機構

Apollo ではキャッシュ機構として InMemoryCache が提供されています。Apollo client に組み込むことで、redux の Store のようにキャッシュを管理することができます。

import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { ApolloClient } from 'apollo-client';

const cache = new InMemoryCache();

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

キャッシュの仕組み、データの正規化

InMemoryCache は、リクエストされたクエリを保存する際に、データの正規化を行います。具体的にはデータオブジェクトに __typename_id ないしは id を設定することで、各データオブジェクトをユニークに扱えるようにします。例をみてみます。以下のような Query に対して、クエリレスポンスのデータオブジェクトにクエリの結果以外に__typenameがついているとします。*1

// Query
place(id: $id) {
      address
      id
      lat
      lng
      name
    }
  }
}

// クエリのレスポンス
{
  address: "63 Ellis St"
  id: "12345"
  lat: 37.785495
  lng: -122.407103
  name: "John's Grill"
  __typename: "Place"
}

この場合、cache には以下のように保存されます。

Place:12345:{...}

もし、__typenameidが設定されていない場合はどうなるでしょうか?その場合はROOT_QUERY.{クエリ名}に順にキャッシュされるようになります。

ROOT_QUERY.place({ id: 49c7dba5f964a520c8571fe3 }):{...}

この場合、クエリが増えると、後述のreadQueryなどでキャッシュから読み込む際に、特定の型のオブジェクトを分離して取得するのが難しくなりますし、Apollo-link などで cache をログする場合に可読性が下がるので、__typenameidは必ず設定するべきです。

ちなみに、このユニーク id の設定のロジックはapollo-cache-inmemoryパッケージのこの辺に書いてあります。

キャッシュのユニークなデータアクセスは__typename:idをキーとすることで可能になります。しかしこれだと__typenameが何なのか、実装者が把握している必要があるし、そもそも Query や Fragment ごとのidがユニークであれば__typenameは冗長です。そこで InMemoryCache では key を dataIdFromObjectで指定することができるようになっています。

const cache = new InMemoryCache({
  dataIdFromObject: object => object.id
});

これで id のみを指定してデータへアクセスが可能となりました。

キャッシュへのアクセス

さて、キャッシュへのアクセス方法は大きく分けて 3 つあります。

(1) ApolloConsumer からダイレクトにアクセスする方法

ApolloConsumer は React の Context Consumer のように Apollo client をサブスクライブして受け取ることが可能です。 受けた Apollo client からキャッシュへ書き込みができます。ただし提供されている writeData メソッドは新たにデータを書き込むなどの単純なキャッシュ更新がしかできないので、あまり使い勝手はよくありません。

// root.js
<ApolloProvider client={client}>
    <MyRootComponent />
</ApolloProvider>

...
// FilterLink.js
const FilterLink = ({ filter, children }) => (
  <ApolloConsumer>
    {client => (
      <Link
        onClick={() => client.writeData({ data: { visibilityFilter: filter } })}
      >
        {children}
      </Link>
    )}
  </ApolloConsumer>
);

参考: https://www.apollographql.com/docs/react/api/react-apollo/#apolloconsumer

(2) resolver 経由でアクセスする方法
より複雑なキャッシュ更新、例えばリストの更新やオブジェクトの書き換えが発生する場合は、resolver を使うことが推奨されています。 resolver はまさにローカルの状態管理を行うための仕組みです。

// resolverの定義
const resolvers = {
  Mutation: {
      toggleTodo: (_root, variables, { cache }) => {
        const id = variables.id
        const fragment = gql`
          fragment completeTodo on TodoItem {
            completed
          }
        `;
        const todo = cache.readFragment({ fragment, id });
        const data = { ...todo, completed: !todo.completed };
        cache.writeFragment({ id, fragment, data });
        return null;
      },
  },
}

// clientの初期化(resloversの設定)
const client = new ApolloClient({
  cache: new InMemoryCache(),
  resolvers,
});

mutation に紐付いた resolver を呼び出す際は、GQL に対して、@client ディレクティブを設定します。

const TOGGLE_TODO = gql`
  mutation ToggleTodo($id: Int!) {
    toggleTodo(id: $id) @client  // <- resolverを見に行く
  }
`;

キャッシュを読み書きする方法も2通りあります。まずは readQuery/writeQuery で、まんま Query を読み書きできます。細かい話なのですが、キャッシュがない場合、readQuery は null ではなく error を throw するんですよね。そのため try/catch で挟むことをおすすめします。あまり良いデザインではないので、この件については何度もissue に上がっているのですが、未だに進捗がないようです。

もう一つは readFragment/writeFragment です。これは対応する id があれば、クエリに依存することなくキャッシュを読み込むことができます。どういう用途で使われるかというと、例えば色々な画面で呼び出されるデータ(アイテム名など)を一度に更新することができます。便利ですね。もちろんこれを使う場合はクエリを Fragment として分割しておく必要があります。

www.apollographql.com

(3) Mutation コンポーネント経由でアクセスする方法
さて、最後に紹介する方法は React Apollo が提供する Mutation コンポーネントを利用したパターンです。 Mutation には update というオプションがあります。これはまさに Mutation の返り値を受けて、キャッシュを更新するためにあります。

以下の例では旅を予約する Mutation の結果を受けて、旅一覧(trips)にデータを追加しキャッシュを更新するということをやっています。

export default function BookTrips({ cartItems }) {
  return (
    <Mutation
      mutation={BOOK_TRIPS}
      variables={{ launchIds: cartItems }}
      update={(cache, result) => {
        const trips = cache.readQuery(TRIPS);
        const newTrip = Object.assign(trips, result.trip);
        cache.writeQuery(newTrip);
      }}
    >
      {(bookTrips, { data, loading, error }) => (...)}
    </Mutation>
  );

なお、 variableupdateprops ではなく以下のように mutation 関数の引数として渡すことも可能です。

export default function BookTrips({ cartItems }) {
  return (
    <Mutation
      mutation={BOOK_TRIPS}
    >
      {(bookTrips, { data, loading, error }) => (
         bookTrips({ variables: { launchIds: cartItems }, update:{(cache, result) => {...}})
)}
    </Mutation>
  );

update 利用時の注意点は、副作用のあるロジックは組み込んではいけないことがあります。なぜなら update は複数回呼ばれるケースがあるからです。具体的には Optimistic Response なのですが、これは次の記事について触れたいと思います。

ちなみに、ただキャッシュ上のオブジェクトを上書きしたい場合は、update は必要ありません。Mutation のレスポンスとして更新したいデータの id と更新後の値を返すことで、キャッシュデータは自動的に更新されます。

const GET_TODOS = gql`
  query GetTodos {
    todos
  }
`;

const UPDATE_TODO = gql`
  mutation UpdateTodo($id: String!, $type: String!) {
    updateTodo(id: $id, type: $type) {
      id
      type
    }
  }
`;

const Todos = () => (
  <Query query={GET_TODOS}>
    {({ data }) => {
      return data.todos.map(({ id, type }) => {
        let input;
        return (
          <Mutation mutation={UPDATE_TODO} key={id}>
            {updateTodo => (
              <div>
                <p>{type}</p>
                <form
                  onSubmit={e => {
                    e.preventDefault();
                    // トランザクション処理が成功すると、キャッシュ上のidを参照して更新される(Queryのdataが更新される)
                    updateTodo({ variables: { id, type: input.value } });
                    input.value = "";
                  }}
                >
                  <input
                    ref={node => {
                      input = node;
                    }}
                  />
                  <button type="submit">Update Todo</button>
                </form>
              </div>
            )}
          </Mutation>
        );
      });
    }}
  </Query>
);

今日はここまで。次回は Mutation 後の更新処理についての指針について触れていきます。

参考ドキュメント:
https://www.apollographql.com/docs/react/essentials/local-state/
https://www.apollographql.com/docs/react/advanced/caching/

*1:"クエリ"は一般的なリクエスト、"Query"はApollo GraphQLのデータ取得処理と使い分けてます