🦥

UIが嘘をつく? ユーザ体験における「楽観的な更新」とSPAでの作り方

2022/01/22に公開

こんにちは、株式会社カミナシでデザインエンジニアをしているショウです。

突然ですが、UI/UX デザインにおいて、「楽観的な更新」という言葉を聞いたことがありますか?あまり聞いたことがなくても、実は日常にたくさん存在しています。

例えば、 twitter や facebook のいいねボタン。いいねをタップした直後に色がつくのですが、実はサーバーへのリクエスト送信と UI 上のいいねの色付きが同時に発生しています。つまりサーバーからの通信結果を待たずに UI を更新しています。

他に、trello でタスクカードを隣の列に移動したり、iMessage や Facebook メッセンジャーでのメッセージの送信、Kindle アプリで途中までしかダウンロード出来てない本が読めることなども楽観的な更新です。

図:楽観的な更新を採用しない時と採用した時のメッセージ送信のUI (引用元

楽観的な更新の意義

早くも 1968 年頃 Robert Miller 氏の論文 "Response Time in Man-Computer Conversational Transactions" で、(コンピューターの)キーを押した後のビジュアル的なフィードバックは 0.1 から 0.2 秒以内に出すべきだと書かれていました。

人間の一回まばたきの速さは平均で 0.1 から 0.15 秒だと言われているので、何かの操作からビジュアルフィードバックまでの遅延時間がそれ以内に納まれば、結果が即座に反映されるためサクサクな操作感が得られます。楽観的な更新は、ユーザーが操作した直後に UI 側が更新されるので、物理的な通信速度などに囚われず遅延のない UI が実現できます。

また、サーバーからのレスポンスを待たずに UI 更新するので、レスポンスを待つ間、ユーザーの操作をブロックする必要もなくなります。 前の操作にブロックされず次の動作に移れるため、ソフトウェアで操作の フロー が中断されにくくなります。
https://ja.wikipedia.org/wiki/フロー_(心理学)

適しているケース

では、どういうどころが楽観的な更新に適しているかというと、

API のリライアビリティがあり、到達性が保証される

楽観的な更新は、高い成功率を前提とした手法です。更新が失敗するときのハンドリングはもちろん組み込まれていますが、失敗率が高いと逆に楽観的な更新を採用しなかった時よりも、マイナスな心理をユーザーに与えてしまいます。

フロントエンド側でレスポンスの結果を予測できる

例えば、バックエンドより複雑な機械学習から結果を返す場合、フロントエンド側で結果の予測ができないので楽観的な更新は作れません。

フロントエンド側、事前にエラーになる要素を排除できる

楽観的な更新は高い成功率を前提とするので、できれば、バックエンドと同じデータバリデーションをフロントエンドでも実装してリクエストの成功率を高めます。

実装例

楽観的な更新の実装をみてみましょう。想定するシーンは、Twitter のようないいねボタンを押す部分の処理です。(いいね数の更新の部分はだいぶ複雑になるので割愛します)

ポイントは、

  • UI を先行して更新する
  • レスポンスが成功した場合の対応(楽観的に更新してあるので、一般的には何もしない)
  • サーバー・レスポンスがエラーになるときのハンドリング(ロールバック)
  • オフラインの対応

辺りです。

使うライブラリは redux-offlineredux-thunk です。

まず、ミドルウェア層を現在の store に追加します:

import { applyMiddleware, compose, createStore } from 'redux'
import { offline } from '@redux-offline/redux-offline'
import offlineConfig from '@redux-offline/redux-offline/lib/defaults'
import thunk from 'redux-thunk'

import rootReducer from './reducers'

const store = createStore(
  rootReducer,
  compose(applyMiddleware(thunk), offline(offlineConfig))
)

export default store

次に action に楽観的な更新の部分、データ通信の部分、通信に成功した部分、失敗した時のロールバックの部分を定義します。

// ...

dispatch({
  type: LIKE_TWEET,
  payload: {
    tweet: { ...tweet, isLike: true },
    tweetId: tweet.id
  },
  meta: {
    offline: {
      effect: {
        url: `/tweets/${tweetId}/like`,
        method: 'PATCH',
        body: JSON.stringify({ tweetId }),
      },
      commit: { type: 'LIKE_TWEET_COMMIT' },
      rollback: {
        type: 'LIKE_TWEET_ROLLBACK',
        meta: {
          tweet,
          tweetId: tweet.id
        }
      },
    },
  },
})

// ...

最後に reducer の action 部分のデータ処理を書きます。

// ...

case LIKE_TWEET:
  return {
    ...state,
    data: state.data.map((tweet) =>
      tweet.id === action.payload.tweetId ?
        action.payload.tweet : tweet
    ),
  }
case LIKE_TWEET_COMMIT:
  return {
    ...state,
  }
case LIKE_TWEET_ROLLBACK:
  return {
    ...state,
    data: state.data.map((tweet) =>
    tweet.id === action.payload.tweetId ?
      action.payload.tweet : tweet
    ),
  }
    
// ...

これで、Twitter のいいねを押したときに、サーバーへリクエストを送信すると同時に、UI は先行して更新するようになります。サーバーから成功したレスポンスが返ってきた時、UI 側は何もせずに、エラーになった場合のみ、リクエスト送信する前の状態にロールバックします。

redux-offline を使うメリットは、アプリケーションがオフラインになってしまう時に、サーバーへのリクエストが一度ペンディング状態になります。この時、UI 側の楽観的な更新には影響しません。ネットが復帰した時に溜まったリクエストが自動的に再開します。

これで楽観的な更新が一通り実装されました。 redux-offlineredux-thunk の組み合わせの他に redux-optimistic-ui も使えます。 redux 以外に mst を使う場合は mst-gql がおすすめです。是非ユーザー体験の改善に使ってみてください。


楽観的な更新に関するおすすめ記事:
Optimistic UIs in under 1000 words
True Lies Of Optimistic User Interfaces
Anatomy of a React application: optimistic updates
The optimistic UI with React
Using optimistic UI to delight your users

Discussion