Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

React Query で optimistic-update な UI を実装してみる

こんにちは @okupara です。 去年から Studyplus for School の API 周りの state 管理を Redux から React Query にし始めました。キャッシュや無限スクロールでのリクエストのサポート、ページネーションのサポートなど、隈雑になりがちな処理を抽象化してくれてるので、かなり使いやすいです。

最近個人的に React Query を使った optimistic-update な UI の実装方法をどうすれば良いのか試行錯誤していたので、現時点での実装案を共有できればと思います。

optimistic-update (optimistic-ui) "楽観的な更新・UI"とはなんぞやについてはカミナシさんの開発者ブログでわかりやすく解説されてます。 Reduxによるいいねボタンの実装例もありますので是非ともそちらををご参考ください。

今回自分が試しに実装してみたかったことは下記の通り

  • 勉強の履歴を入力する簡単なフォーム
  • 一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加える
  • サーバーからのレスポンスが返ってくるまで、その行はローディングモードの表示をする
  • サーバーから成功のレスポンスが返ってきたら、通常の一覧のレコードと同じ表示にする
  • サーバーからエラーが返ってきたら、その行はエラーの表示にする
  • エラーの場合はリトライできるように、リトライボタンを配置
    • 押されたら、再度上記のローディングモード〜のフローになる

こんな感じです

React Query の公式ドキュメントにも下記のような optimistic-update の実装例が載っているので、参考にしつつ実装しています。

 const queryClient = useQueryClient()
 
 useMutation(updateTodo, {
   // When mutate is called:
   onMutate: async newTodo => {
     // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
     await queryClient.cancelQueries('todos')
 
     // Snapshot the previous value
     const previousTodos = queryClient.getQueryData('todos')
 
     // Optimistically update to the new value
     queryClient.setQueryData('todos', old => [...old, newTodo])
 
     // Return a context object with the snapshotted value
     return { previousTodos }
   },
   // If the mutation fails, use the context returned from onMutate to roll back
   onError: (err, newTodo, context) => {
     queryClient.setQueryData('todos', context.previousTodos)
   },
   // Always refetch after error or success:
   onSettled: () => {
     queryClient.invalidateQueries('todos')
   },
 })

useMutationonMutate onSuccess onError onSettled のコールバックを持っているので、それらを使えば良さそうです。

onMutate と コンテキスト

「一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加わる」を実装するには、先ほどの公式ドキュメントの実装例にも使われている、useMutationonMutate コールバックを使います。 これは mutate が呼ばれた直後、Promise の処理が走る前に実行されます。第一引数は mutate の引数で指定したデータが渡されます。 ポイントは戻り値に何かを返すと、それがそのリクエストのコンテキストデータとして onSuccessonError で参照できることです。

export function useSaveStudyTime(props: Props) {
  const { onMutate, onSuccess, onError } = props;
  const client = useQueryClient();

  return useMutation<StudyTime, Error, StudyTimeFormData, StudyTime>(
    async (params) => {
         ........
    },

  onMutate(variable) {
  .....
    const dataInProcess: StudyTime = {
      // まだサーバーにリクエストしてないので、暫定 ID を振ってローディング中のデータを特定できるようにする
      id: `loading-${id}`, 
      status: "loading",
      ...params
    };
    client.setQueryData<StudyTimeList>(cacheKey, (previous) =>
      previous ? [dataInProcess, ...previous] : []
    );
    onMutate?.(dataInProcess);
    // ここで返したデータは、コンテキストデータとして、onSuccess 時、 onError 時に参照できる
    return dataInProcess;
  }

入力されたデータに、status=loading と暫定の ID を付与したデータ dataInProcess をキャッシュの一覧に入れることで、即座にデータ一覧に反映されます。

一覧に表示するデータには全て status というフィールドを生やしています。

  • 通常のデータ (done)
  • ローディング中 (loading)
  • エラー (failed)

このフィールドを行ごとに、ローディング中のスタイル表示やエラーのスタイル表示の判定をするために使います。 よって、一覧のデータ取得時にサーバーから返ってくるデータは、Promise 側の処理で全てに status=doneをつけています。

また、onMutate?.(dataInProcess) で props で渡されたコールバックを実行していて、内部ではローディング中のデータを親の state に保持しています。 これはローディング中に React Query のデフォルト機能である、window を切り替えて戻ったときに refetch する機能が動作した時のためのものです。 その機能が動作して refetch の結果が登録処理より先に返ってきてしまうと 先ほどの、onMutate 内にて setQueryData でキャッシュに追加したローディング中の status を持つデータが refetch で返ってきたデータで上書かれ、ローディング中の行が消えてしまうのを防ぐのが目的です。

refetch 時に消えてしまっても表示が維持できるように、別途 state でも処理中のデータを管理して、一覧取得のAPIから取得したデータとマージして表示するようにします。

先ほどの公式ドキュメントを見る限りcancelQueries でキャッシュをアップデートさせないということができるようなのですが、どうも期待した動きにならなかったので、やむなくこの方法をとっています。 もし、cancelQueries でもこのユースケースがカバーできる方法を知ってる方がいたら教えていただけるとありがたいです・・・

公式ドキュメントの実装例では、onMutate 時点でキャッシュにあるデータを取得してコンテキストデータとして返し、エラーの時にそのデータでキャッシュを上書きしてます。 これによって、入力したはずのエラーのデータ自体は消えるようになっています。

今回は、エラーの行はそのまま残しつつ、エラーが起きた行を分かるように表示したいです。さらにエラー行内のリトライボタンから再登録を促したいので、上記の方法は取れません。

よって、先ほどのdataInProcessをそのまま返して処理中のレコードとして扱いonSuccessonError から特定できるようにします。

これで、「一つ履歴を入力すると、サーバーからのレスポンスを待たずに履歴一覧に加わる」「登録処理中の行はローディング表示」の実装が整いました。

成功時

データ登録処理に成功した場合は先述の通り、useMutation に指定した onSuccess コールバックが実行され、第三引数でコンテキストのデータが受け取れます。 先ほど、ローディング表示する前に暫定IDを振ったdataInProgressを state に保存していました。 ここでは別途 props で受けた onSuccess コールバックを実行しています。 その内部ではそのコンテキストデータから state にローディング中のデータとして保存していたものを特定し、削除する処理を行っています。

onSuccess(data, varialbes, context) {
  if (context) {
   // props から別途渡される onSuccess
    onSuccess(context);
  }
},

こちらは useMutation で指定できる onSettled コールバックです。 これは成功もしくはエラーにかかわらず、処理が終わった時に呼ばれるコールバックです。

ここで ReactQueryClient の invalidateQueries を呼んで、API から最新のデータを取得します。 このタイミングで state に保存していたデータとレスポンスデータをマージして表示されます。 登録に成功したデータは先ほどの onSuccess のコールバックによって state から削除されているので、ローディング中から通常のデータに切り替わったように見えます。

onSettled() {
  client.invalidateQueries(cacheKey);
}

エラー時

ここからさらに話がややこしくなってきます。エラーの時にやりたいことは

  • ローディング中だった行をエラー用のスタイルが当たった行に表示を変更する
  • ボタンを表示して、リトライを促す
  • リトライが押されたら、再度ローディングのフローに戻る

なので、ローディング中だったレコードの status を failed にして、該当の行をエラー表示できるようにします。

こちらも useMutation で指定できる onError コールバックにて、 props から別途渡される onError を実行しています。 その内部では先ほどのコンテキストデータからレコードを特定し status を failed に更新して state に保持しています。

onError(error, variables, context) {
  if (context) {
    onError(context);
  }
},

先ほど "成功時" のところでも書いた、onSettled がエラーの時にも実行されるので、再度 API から取得する処理が走ります。この際、先ほど state に保存しているエラーのレコードがマージされて表示されるので、該当の行がローディングからエラー表示に切り替わったように見えます。

リトライ

ようやく最後ですね。 エラー行のリトライボタンが押されたら、もう一度登録処理を実行しつつその行をまたローディング表示にしたいです。 登録時の onMutate では、最初の "onMutate と コンテキスト" では送られた入力データを一律キャッシュ一覧に 追加 することで、ローディング表示していました。 しかしながら、入力データはエラーのレコードとしてキャッシュもしくは state の中に既に存在しているので、そのデータを特定して、status = "loading" に更新したいです。 なのでフォームから登録された場合に "New" という属性を 、リトライから登録された場合には "Update" という属性を一緒に送って、onMutate の中で条件分岐することにしました。

フォームに並んでる登録ボタンの onClick コールバック

onClick: () =>
  mutate({
    type: "New",
    material: formState.material,
    minutes: formState.minutes
  })

RETRYボタンの onClick コールバック

onClick={() => {
  mutate({ ...item, type: "Update" });
}}

先ほどの useMutationonMutate の中身に、下記のように "Update" の場合の処理を追加します。 "Update" の属性と一緒に送られてきたデータから、キャッシュの中の status がエラーのものを特定し、ローディングに更新して再度キャッシュに登録し直します。 そして"New" の場合は先に説明した追加処理で対応するようにします。

追加の時にも使っていた propsから渡される onMutate は内部で state の一覧に存在しないデータは追加し、同じIDを持つデータがある場合は、status をローディングに更新して保存しています。 これにより再度ローディング中のフローに入ることができまます。

onMutete(params) {
    if (params.type === "Update") {
      const queryData = client.getQueryData<StudyTimeList>(cacheKey);
      if (queryData) {
        // React Query のキャッシュを走査して
        // 該当レコードがあれば status="loading" に更新したデータを作る
        const newStudyList = queryData.reduce<StudyTimeList>((p, c) => {
          if (c.id === params.id) {
            return [...p, { ...c, status: "loading" }];
          }
          return [...p, c];
        }, []);
        // 更新したデータでキャッシュを上書き
        client.setQueryData<StudyTimeList>(cacheKey, newStudyList);
      }
      // props からの onMutate コールバックで state 側の一覧も更新する
      onMutate?.(params);
      return params;
    }
    // "New"の場合は先述の追加処理を実行する
}

以上が今回試行錯誤した結果となります。 個人的に試してみたかったものなので、プロダクションで実装しているものではないのですが、いつかどこかでこういったUIも取り入れられると良いなーと思いつつ試行錯誤していました。

今回の勉強履歴の登録のような基本的に自分しか登録・編集しないデータについては、optimistic-update は効果を発揮しそうです。 反対に、複数人が同一レコードを同一タイミングで編集し合う可能性があるようなアプリケーションで、表示の整合性も担保しなければならないケースでは不向きかもしれません。

コードはsandboxにおいているので適宜ご覧いただければと・・・ 上記解説の通り、やむなく React Query 側のキャッシュと state でデータを二重管理していたりと、結構泥臭くなってしまってます。。。 もっとエレガントにかける方法を思いついた方は是非教えてください!