UX
UI
reactjs
React
redux

Reactとtoggle型UIの非同期処理問題

toggle型UIのユーザー体験

toggle型(ユーザーが切り替え可能なオン・オフの2つの状態を持つ)のUIを実装する場合、
通常、切り替え時に何らかの非同期処理を呼ぶことになると思います。

それがサーバ側での更新処理を伴う場合、通常はレスポンスを待ってからユーザーへのフィードバックをするかと思いますが、その間ユーザーを待たせてしまうことになり、またレスポンスタイムはユーザーの環境に左右されるため、特に頻繁に押されるボタンの場合にはUXを損ねてしまう可能性があります。

実際の動きを見てみます。

iOSでDeveloperメニューのNetwork Link Conditionerを使います。Very Bad Networkに設定し、通信状態が非常に悪い状況を作ります。

Network Link Conditioner

この状態で、例えばDMM英会話の「お気に入り解除」ボタンを押すと、
ボタンを押してからハートマークが切り替わるまでに
秒単位のラグが生まれてしまいます。

DMM英会話(iOS Web)

また、こちらはメルカリの設定画面ですが、二重送信や完了前の画面遷移を防ぐため、このように画面全体をブロックしてしまうのもよく見る実装です。

メルカリ(iOS App)

ユーザーの操作をブロックせず、かつ、直ちにフィードバックする実装が望ましいと言えます。

Optimistic Updates

一方、Twitter、Facebook、InstagramのLikeボタンは、ボタンを押した瞬間に処理が成功したものと見なされて即座にフィードバックがあり、その後バックグラウンドでAPIコールをする実装になっていると思われます。これがもしLikeの更新処理に失敗すると、その場では成功したように見えていても、のちに画面を更新すると元に戻ってしまいます。

TwitterでのLikeボタン(iOS App)

これは、そもそも端末がオフラインの状態でボタンを押した場合でも同様の動きになっており、特にアラートなどは出ません。その後リクエストがタイムアウトするまでにオンラインへ復帰すれば、pendingとなっていたリクエストが実行されるようです。楽観的に更新処理を行っていると言え、これをOptimistic (UI) Updatesと呼ぶようです。

また、サーバからのレスポンスをハンドリングするかどうかについては、toggleのUIが持つ意味によって対応が分かれているようです。

Twitter iOSアプリの場合、前述の通り、お気に入りは失敗しても無視、つまりサーバ上の状態とアプリ上の状態が異なったままで、他のアクションや画面遷移が許されていました。画面をリフレッシュしてサーバ上のデータと同期したタイミングで、暗黙的にズレが解消されることになりますが、これが問題になるケースは稀であるという判断で許容しているものと思われます。

しかし同じTwitter iOSアプリの設定画面のスイッチでは、押した瞬間に切り替わるOptimistic Updatesであるものの、リクエストが失敗したタイミングでUISwitchの状態を元に戻していました。画面上のLikeボタンが勝手に動くため、ユーザーにとって多少の違和感はありますが、設定の状態にズレが生じないことの方を重視した結果と思われます。

Twitter設定画面のスイッチ(iOS App)

Optimistic Updatesにおいて、非同期処理の結果を画面に反映させるべきかどうかは、

  • 1セッションにおける実行頻度が高いかどうか
  • コンバージョンに直接影響するかどうか、またはユーザにとっての重要性が高いかどうか
  • サーバ側と状態が乖離していても、その後アプリ上で問題が起きないかどうか

といった点を考慮した上で決めるのが良さそうです。

Reactの実装

前述の同期的にレスポンスを待ってから更新するケースでは、Reduxのstore(もしくはcontext APIなど)から直接Like状態を取り出して、Likeボタンのコンポーネントにpropで渡しておけばよかったのですが、Optimistic Updatesの場合にはそうすることが出来ません。

これをReactで実装する上では、いくつかの方法が考えられます。Like状態の参照先をもとに、2つに分けます。

1. Like状態はcomponentの内部stateを使う

Like状態をcomponentの内部stateとして持ち、ボタンイベントに合わせてtoggleする方法です。initialStatecomponentWillMount 時に渡します。

const doAsyncRequest = () => {
  // do async request
}

class App extends React.Component{
  constructor(props) {
    super(props)
    this.state = {
      liked: false,
    }
  }

  componentWillMount() {
    const { initialLikedState } = this.props
    this.state = {
      liked: initialLikedState,
    }
  }

  onPressButton = () => {
    this.setState({liked: !this.state.liked})
    doAsyncRequest()
  }

  render() {
    return (
      <div className="app">
        <div className="title">
          { this.state.liked ? "👍" : "👎" }
        </div>
        <div className="button">
          <button onClick={this.onPressButton}>
            Toggle
          </button>
        </div>
      </div>
    )
  }
}

JS Bin

サーバから返されるレスポンスについては一切責任を持たない、最もシンプルなやり方です。この方法の場合、Like状態の参照がstoreから切り離されてしまうので、componentがmountされたあとに別のトリガーによってstoreのLike状態が変わってしまうと、リアクティブに追随できなくなります。また、他の場所でも同じLike状態を表示しないといけないケースでは、何らかの方法で状態を共有しなければならなくなります。

2. Like状態はstoreを直接参照する

Like状態は、storeにあるstateを参照します。その上で、ボタンイベントがトリガーされた時点で、非同期処理の手前で、レスポンスを待たずにstoreのstateを更新してしまいます。レスポンスを受けてその後stateを再度更新するかどうかは、前述の通り、状況次第です。

もしレスポンスが失敗のケースでundoする場合、予めstateの履歴を保持しておく必要があるかもしれません。

以下のようなライブラリを使うと、stateにhistoryを持たせることができます。リクエストの成功/失敗時に、stateがcommit/revert可能になるようです。ただ、stateに更にstateを持たせている状態になってしまうのがちょっと複雑に思えて、個人的には気になるところです。

redux-optimistic-ui
redux-optimist

その他

この記事では「toggle型UI」という風に呼んでいますが、画面遷移を伴わずに更新処理が走るUI(例えばレーティングなど)であれば、状態が3種類以上あったとしても同じ議論になると思います。

またなにか気づいたら、追記しようと思います。