前回は、Apollo GraphQL における Mutation(トランザクション処理)の前提知識としてキャッシュ機構について説明しました。(記事はこちら)
今回は React Apollo(v2) における Mutation 後のキャッシュ更新処理について整理しつつ、どのパターンをどのユースケースで採用すべきかについてまとめてみました。
Mutation 後のキャッシュ更新処理
Mutation後のキャッシュ更新処理は大きく分けて3種類あります。それぞれ順番に紹介していきます。
Mutation の返り値を使う(Optimistic UI/update)
まず、一番ベーシックな方法として、Mutation の返り値を使って、キャッシュを更新しビューに反映させる方法があります。
例えば記事に対してコメントを追加するトランザクション処理の場合、その返り値として追加したいコメントを返し(通常は引数として渡した値と同じはず)、update
でキャッシュのコメントオブジェクトに追加することができます。
const UPDATE_COMMENT = gql` mutation UpdateComment($commentId: ID!, $commentContent: String!) { updateComment(commentId: $commentId, commentContent: $commentContent) { id __typename content } } `; const CommentPageWithData = () => ( <Mutation mutation={UPDATE_COMMENT}> {mutate => { <Comment updateComment={({ commentId, commentContent }) => mutate({ variables: { commentId, commentContent }, update: (cache, result) => { const comments = cache.readQuery(COMMENT); const newComments = Object.assign(comments, result.content); cache.writeQuery(newComments); }, }) } />; }} </Mutation> );
なお、レスポンスの値でキャッシュを上書きしたいだけの場合は、レスポンスに id を返すようにします。すると Apollo Client は id に紐付くキャッシュを自動的に更新します。(詳細は前回の記事にあります)
さらに、この応用として Optimistic Response を設定することができます。これはサーバーのレスポンスに期待される値を設定することで、リクエストの結果を待たずしてビューを更新するというものです。本当のレスポンスが返ってきた場合は、Optimistic Response によるキャッシュ更新はロールバックされて、本来の値に置き換えられます。
上の例のコメント追加の場合、追加されるコメントはサーバーからのレスポンスをまたずとも明らかなので、それを Optimistic Response として設定します。
const UPDATE_COMMENT = gql` mutation UpdateComment($commentId: ID!, $commentContent: String!) { updateComment(commentId: $commentId, commentContent: $commentContent) { id __typename content } } `; const CommentPageWithData = () => ( <Mutation mutation={UPDATE_COMMENT}> {mutate => { <Comment updateComment={({ commentId, commentContent }) => mutate({ variables: { commentId, commentContent }, // 更新処理が成功した場合のレスポンスを返す optimisticResponse: { __typename: "Mutation", updateComment: { id: commentId, __typename: "Comment", content: commentContent } } update: (cache, result) => { const comments = cache.readQuery(COMMENT_QUERY); const newComments = Object.assign(comments, result.content); cache.writeQuery(newComments); }, }) } />; }} </Mutation> );
この場合 update
は Optimistic Response と実際のサーバーレスポンスの2回処理が呼ばれます。
Refetch
Mutation 後に、クエリをリクエストし、必要な Query を更新する Refetch という方法があります。例えば、コメントした後に、コメンターの返信ステータスや通知ステータスを更新したい場合を考えてみます。それぞれの Query が別々であっても、以下のように refetchQueries
内でクエリをリクエストしてキャッシュを更新することが可能です。複数のキャッシュを更新できるのが Refetch のメリットですね。
const CommentPageWithData = () => ( <Mutation mutation={UPDATE_COMMENT}> {mutate => { <Comment updateComment={({ commentId, commentContent }) => mutate({ variables: { commentId, commentContent }, ... refetchQueries: [ { query: COMMENTER_STATUS, }, { query: NOTIFICATION_STATUS, } ] }) } />; }} </Mutation> );
Subscription
もう一つの方法として、サーバー側の通知を受け取り更新クエリを実行する、いわゆる Subscription を利用する方法があります。
Subscription の実装方法については公式ドキュメントを参照してもらうとして、React Apolloのコンポーネントを利用したキャッシュ更新について説明します。具体的には React Apollo の Query コンポーネントに提供されている subscribeToMore
を利用します。
ここでは記事(thread)にコメントが追加されたことを想定して、 COMMENTS_SUBSCRIPTION
という Subscription 用のクエリを用意します。
const COMMENT_QUERY = gql` query Comment($threadId: String!) { entry(threadId: $threadId) { comments { id content } } } `; const COMMENTS_SUBSCRIPTION = gql` subscription onCommentAdded($threadId: String!) { commentAdded(threadId: $threadId) { id content } } `;
そして Subscription は以下のように Query に組み込みます。
const CommentsPageWithData = ({ params }) => ( <Query query={COMMENT_QUERY} variables={{ threadId: params.id }} > {({ subscribeToMore, ...result }) => ( <CommentsPage {...result} subscribeToNewComments={() => subscribeToMore({ //(1) document: COMMENTS_SUBSCRIPTION, // (2) variables: { threadId: params.id }, // (3) updateQuery: (prev, { subscriptionData }) => { // (4) if (!subscriptionData.data) return prev; const newFeedItem = subscriptionData.data.commentAdded; return Object.assign({}, prev, { // (5) entry: { comments: [newFeedItem, ...prev.entry.comments] } }); } }) } /> )} </Query> );
少々複雑なので、順を追って説明しましょう。
(1) では Query から提供された subscribeToMore
を subscribeToNewComments
の関数として呼び出しています。
subscribeToMore
では (2) gql (3) 引数 (4) updateQuery
が option として設定されています。 updateQuery
は一つ前の Query によって取得されたデータと、Subscription による結果を返します((4)の prev, subscriptionData)。Subscription の結果を追加して返すこと(5) で、キャッシュは更新され、ビューの再描画が実行されるわけです。
さて、この Subscription を利用する場合は、このコンポーネントを呼び出すコンテナで Subscribe する必要があります。Subscribe する箇所は Subscription の重複購読を避けられる componentDidMount
ないしは Query による初期データが揃ったことが確認できる getDerivedStateFromProps
が良いプラクティスのようです。
export class CommentsPage extends Component { componentDidMount() { this.props.subscribeToNewComments(); } // または static getDerivedStateFromProps(nextProps: Props, prevState: State) { // すでに Subscription が設定されているか確認する if(nextProps.result && !prevState.unsubscribe) { // subscribeToMore は unsubscribe を返すので、stateに保存しておく (後述) return { unsubscribe: this.subscribeToNewComments(), } } return null; } }
また subscribeToMore
は unsubscribe
関数を返します。これにより画面を閉じたときに Subscription を終了させることも容易にできます。
export class CommentsPage extends Component { unsubscribe: () => void, componentDidMount() { unsubscribe = this.props.subscribeToNewComments(); } componentWillUnmount() { unsubscribe(); } }
https://www.apollographql.com/docs/react/advanced/subscriptions/
どう使いわけるか
さて、これらのキャッシュ更新処理はどう使い分けるのが良いでしょうか。下図のような分類パターンを考えてみました。
まず、Mutation のレスポンスだけでなく、サーバー側からの通知によってキャッシュを更新したい場合は Subscription を選択するべきでしょう。*1
次に確認すべきは、Mutation 後に更新すべきキャッシュが複数ある、ないしはキャッシュのデータ構造が複雑かどうかです。例えば認証系のトランザクションではユーザーデータの一括更新をするケースがあります。このような場合は Refetch を検討すべきです。
キャッシュ更新がシンプルである、または GQL のデータ構造の一部のみ更新が必要なケースであれば、Mutation の返り値をupdate()
で受け取るのが素直な実装でしょう。
もちろんそれぞれの実装についてトレードオフはあります。
Subscription
- websocket などの少々複雑な実装がクライアント、サーバーともに必要
Refetch
- モバイルアプリの場合、ネットワーク環境は不安定であるため、Refetch が失敗し、データ不整合が起きる可能性がある
- Query のすべてのキャッシュを上書きしてしまうので、1 データを追加/削除するような更新には適してない
Update(Optimisitic UI)
- Mutation の返り値についてしっかりとした認識合わせをサーバーサイドとする必要がある
最後に、twitter 上でもこの更新処理に関する活発な議論があったので、ここにスレッドといくつかの意見(意訳)を掲載しておきます。
GraphQL Gurus:
— swyx 🌟 (@swyx) July 14, 2019
in what scenarios is it better to use returned values from a mutation, rather than simply refetching queries (or subscriptions)?
i see people patching their UI with returned values and it feels overkill for the slight perf gain. was this the original intent?
どのようなユースケースで、シンプルに Refetch するよりも Mutation の返り値を使ったほうがよいでしょう? 返り値で UI を更新するのは、わずかなパフォーマンス改善に対して割りに合わないと僕は思うのです
I suppose it's used for when that mutation is actually a create, and you need the id so you can go and refetch. We wrap Braintree's API, so when mutate to make a payment, the response brings back ids, so we can then query to get a list of payment methods.
— Marais Rossouw (@codervandal) July 14, 2019
Mutation がサーバー側で実際のデータを生成するときに Mutation の返り値を使うのだと思います。僕たちは Braintree(決済サービス) API を wrap していて、支払時に Mutation の結果で id を返すことで、支払い方法をクエリすることができるようになります
Totally agree. I even have an app where I refresh whole state on some cases as it's not expensive. Who cares, keep it simple and optimize later if ever needed
— Sebastien Lorber (@sebastienlorber) July 15, 2019
自分のアプリでは、大した負荷にもならないので、アプリ内のすべての State を Refetch で更新することもあるね。まずはシンプルに実装して、パフォーマンスに問題出てから他の方法を検討すればよいのでは。
My mental model for a mutation isn't a function that returns data. It's a function that mutates the graph from its old state to a new one, and the "return value" is one or more entry points into the parts of the graph which have changed.
— Andy Ingram 🌀 (@andrewingram) July 14, 2019
自分の中でMutationとは、単にデータを返す関数ではなく、グラフを古い状態から新しい状態へ変化させる関数という認識です。返り値はグラフの変化をたどるエントリーポイントです。
GraphQLの更新処理のハンドリングについては皆が手探りの状態で、ベストプラクティスというものがまだ存在していません。それゆえにどういう方針で更新処理を実装すべきかは、チームで話し合っておくべきかもしれませんね。
*1:リアルタイム性を重視しないなら、Polling も可能です