🥑

【Apollo Client】楽観的更新を利用してユーザー体験を向上させる方法【UX】

2023/04/17に公開

まとめ

例えばいいねボタンに押した後、数秒UIが変化しなかった場合、

ユーザーは「本当に押せているのかな。。。?」と不安になってしまいます。

「いいねボタンに押した後、数秒UIが変化しなかった場合」

上記のような問題は、以下のフローで起こると思います。

  1. フロントエンドからいいねをupsert(挿入、更新)するためのクエリを叩く
  2. バックエンドで処理し、レスポンスを返す ex)数秒かかるとする
  3. レスポンスの結果を待ってから、フロントエンドのUIを更新させる

この場合、原因は2の部分です。
2のバックエンドがレスポンスを返すまでに数秒要する場合、フロントエンド&ユーザーは数秒待たなければいけないからです。

これらの問題は、楽観的UI更新を用いて、
以下のフローに変えることでユーザー体験を向上させることができます。

  1. フロントエンドからいいねをupsert(挿入、更新)するためのクエリを叩く
  2. 1の直後にUIを更新する && バックエンド処理開始
  3. バックエンドで処理し、レスポンスを返す
  4. 必要な場合、レスポンスの結果から、フロントエンドのUIを更新させる

①と比べて②の場合では、バックエンドの処理を待つ必要がなく、すぐにUIが更新されるので、
ユーザビリティが高いと言えます。(楽観的UI更新)

4の「必要な場合、レスポンスの結果から、フロントエンドのUIを更新させる」というのは、

いいねボタンを押して、UI上ではactiveになっているが、②-3でupsertに失敗したとのレスポンスを受け取った時にUIを非activeに変更するというケースを考えることができます。

このような楽観的更新を実装するためにapollo clientでは、
useMutationのoptionとして「optimisticResponse」を用意しています。
今回をそれを活用したコードを書いていきたいと思います。

コードに落とし込む

ここからの読者対象
apollo clientのキャッシュの仕組みについて、ちょっと知っている

https://www.apollographql.com/docs/react/performance/optimistic-ui/

optimisticResponseオプションを利用することにより、
ミューテーションがレスポンスを返す前に、レスポンス結果を事前に予測しておき、
mutationを叩いた直後にUI更新をすることができます。

バックエンドにレスポンスが遅くなる処理を仕込んでおきます。

sleep(10);

のようにして、いいねボタンを押してから、10秒はレスポンスが帰ってこないように実装しました。

フロントエンドコード

色々省略したり、ささっと書いてます。

import React from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { MdFavorite, MdOutlineFavoriteBorder } from "react-icons/md";

const Post = () => {
//クエリ
  const { data, loading } = useQuery(
    gql`
      query fetchPostById($id: ID!) {
        fetchPostById(id: $id) {
          id
          is_favorite
          content
        }
      }
    `,
    {
      variables: {
        id: 1,
      },
    }
  );

//ミューテーション
  const [updateFav] = useMutation(
    gql`
      mutation updatePost($id: ID!, $is_favorite: Int!) {
        updatePost(id: $id, is_favorite: $is_favorite) {
          id
          is_favorite
        }
      }
    `,
    {
      variables: {
        id: 1,
        is_favorite: data?.fetchPostById.is_favorite === 0 ? 1 : 0,
      },
      //このオプションを使って楽観的UI更新を実現する
      //レスポンスとして、想定できるデータを定義しておく
      optimisticResponse: {
        updatePost: {
          id: "1",
          __typename: "Post",
          is_favorite: data?.fetchPostById.is_favorite === 0 ? 1 : 0,
        },
      },
    }
  );

  return (
    <div>
      <div>
        {loading ? (
          <div>ローディング中、、、</div>
        ) : (
          <>
            <div>{data.fetchPostById.id}</div>
            <div>{data.fetchPostById.content}</div>
            {data.fetchPostById.is_favorite ? (
              <MdFavorite
                onClick={async () => {
                  await updateFav();
                }}
              />
            ) : (
              <MdOutlineFavoriteBorder
                onClick={async () => {
                  await updateFav();
                }}
              />
            )}
          </>
        )}
      </div>
    </div>
  );
};

export default Post;

ポイント

optimisticResponseの値は、レスポンスのデータのオプジェクトの形式と一致させる必要があります。
加えて__typename:IDのように、キャッシュID識別子を形成できるように、
__typenameフィールドとidフィールドを含ませる必要があります。

ドキュメントをもとに、Optimistic mutationのライフサイクルを簡単にまとめると
https://www.apollographql.com/docs/react/performance/optimistic-ui/#optimistic-mutation-lifecycle

  1. mutateが呼び出されると、__typename:IDのような形でoptimisticResponseで指定されたオブジェクトがキャッシュされます。
    また既にPost:1がキャッシュされていた時、optimisticResponseでキャッシュされるものがPost:1の場合は、上書きすることはなく、楽観的バージョンとして個別に保存します。
    なのでoptimisticResponseで指定したデータが間違っていたとしても、既存のキャッシュを汚すことはありません。

  2. クエリがキャッシュの変化を感知して、データ反映のためにレンダリングします。

  3. ミューテーションより、実際のレスポンスを取得する。

  4. 楽観的バージョンのキャッシュを削除して、通常通りキャッシュを保存する

  5. クエリがキャッシュの変化を感知して、データ反映のためにレンダリングします。楽観的バージョンのキャッシュと実際に保存するキャッシュが一致する場合は、ユーザー視点でUIの変化は見られない。

↑apollo clientに慣れ親しんだ人にとってはイメージしやすい流れだと思います。

      //このオプションを使って楽観的UI更新を実現する
      //レスポンスとして、想定できるデータを定義しておく
      optimisticResponse: {
        updatePost: {
          id: "1",
          __typename: "Post",
          is_favorite: data?.fetchPostById.is_favorite === 0 ? 1 : 0,
        },
      },
    }
  );

optimisticResponseでレスポンスを予測して定義しておきます。
クエリで取得した際にis_favoriteが0だとしたら、いいねを押したらレスポンスの
type Postのis_favoriteフィールドは1になるはずです。その逆も。

そして上記で説明したライフサイクルが働き、楽観的UI更新を実現させることができます。
このようにレスポンスが想定しやすいと、楽観的UI更新は実装しやすいです。

動作スクショ

いいね押す前

いいね押した後
すぐUIが更新される

しかし、ネットワークタブからステータスを確認すると保留中となっており、
200は帰ってきていない。

Discussion