弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

Optimistic Update に触れてみる

この記事は弁護士ドットコム Advent Calendar 2023の 19 日目の記事です。

前日は @michimani さんの「Azure App Configuration を使って機能の利用可否を自動で切り替える」でした。


クラウドサインのフロントエンドエンジニアの山田です。 普段の業務では新規機能の開発をしています。

Optimistic Update

この数年、フロントエンド開発や UX/UI の文脈で「Optimistic Update(楽観的更新)」という言葉をよく見るようになりました。

Optimistic Update とは、簡単にいうとユーザーのアクションに対して結果が予測されるものはサーバーからのレスポンスを待たずに表示を更新し、体感的に素早い UI を実現するアプローチです。 「結果が予測される」というのは、例えば SNS などで「いいねボタン」を押してカウントが追加されることや、ToDo リストに入力した ToDo が追加されることなどが思いつきます。

最近では React の useOptimistic の登場やいくつかのフレームワークやライブラリで Optimistic Update に言及されていてアプローチとして定着しつつある印象です。

保留中の UI

ユーザーのアクションに対してサーバーからのレスポンスを待つインタラクションの UI は「保留中の UI」と言えます。 保留中の UI としてはローディング時のグルグル(Spinner, Busy Indicator など)、スケルトンスクリーンなどのアプローチもよく目の当たりにします。

そういった UI については Remix フレームワークのドキュメントにそれぞれの特性や最適な選択のための考え方が記載されており参考になりました。

その中で Optimistic Update はアクションに対して、次の状態の予測可能性が高いこと、URL が変わらないことなどが選択の指標として挙げられています。

次の状態の予測が難しい場合やレスポンスを待って URL が変更になる場合は Busy Indicator が相応しいとされています。 また検索結果を待つ場合のように次の状態の構造は予測できるが、中のデータまでは予測ができないなどの場合はスケルトンスクリーンが相応しいなどと書かれています。

Optimistic Update は比較的新しいアプローチで、ユーザーへの応答性も高いですが、必ずしも万能ではなくあくまでもそのシナリオに相応しいアプローチを選択することが大切です。

実装

試しに Vue.js で Optimistic Update を実装してみます。

作るのはいわゆる「いいねボタン」です。

実直にレスポンスを待って「いいね」のカウントを反映する場合、以下のような実装が考えられます。 (わかりやすさのためいろいろ簡潔にしています)

<script setup lang="ts">
import { ref } from 'vue'
import { sendLike } from '../modules/api'

const count = ref(0)

const like = async () => {
  try {
    const newCount = await sendLike() // ここでレスポンスを待ちその結果の値を count に代入する
    count.value = newCount
  } catch (e) {
    console.error(e)
  }
}
</script>

<template>
  <div>
    いいね数 : {{ count }}
    <button @click="like">いいね</button>
  </div>
</template>

sendLike() でレスポンスを待っている間は count に新しい値は反映されず応答性がいいとは言えません。

一方 Optimistic Update を導入した場合は以下のような実装が考えられます。

<script setup lang="ts">
import { ref } from 'vue'
import { sendLike } from '../modules/api'

const count = ref(0)
const countTmp = ref(0)

const like = async () => {
  try {
    countTmp.value = count.value
    count.value++ // カウントが増えることが予測されるためレスポンスを待たずに値を更新する

    const newCount = await sendLike()

    count.value = newCount // サーバーからの値で更新する
    countTmp.value = newCount

  } catch (e) {
    console.error(e)
    count.value = countTmp.value // なんらかのエラーの場合、カウントを元に戻す
  }
}
</script>

<template>
  <div>
    いいね数 : {{ count }}
    <button @click="like">いいね</button>
  </div>
</template>

Optimistic Update の場合、コメントしているとおりレスポンスを待たずに値を更新します。その後レスポンスがあってから正しい値(他のユーザーの「いいね」の数を考慮)を代入しなおします。またエラーの際に元の値に戻するなどの処理も必要と考えました。

Fallback の改善

上記の実装ではエラー発生時の fallback のため countTmp に更新前の値を保存しています。 これを computed を利用した実装にしてみます。

<script setup lang="ts">
import { computed, ref } from 'vue'
import { sendLike } from '../modules/api'

const count = ref(0)
const isProcessing = ref(false) // 送信中どうか

const countOptimistic = computed(
  () => (isProcessing.value ? count.value + 1 : count.value) // 送信中であれば count に対して 1 を追加する
)

const like = async () => {
  if (isProcessing.value) {
    return
  }
  try {
    isProcessing.value = true

    const newCount = await sendLike()
    count.value = newCount
  } catch (e) {
    console.error(e)
  } finally {
    isProcessing.value = false
  }
}
</script>

<template>
  <div>
    いいね数 : {{ countOptimistic }}
    <button @click="like">いいね</button>
  </div>
</template>

countOptimistic という computed を作成しました。

isProcessing で送信中かどうかを管理し、送信中であれば既存の count に対して +1 をした値を返します。

レスポンスを受け isProcessingfalse になったタイミングでは count にはレスポンスからの値が代入されています。 もしエラーがあった場合も count は更新前の値を返すので fallback を意識する必要がない実装になっています。

ユーザーへの表現

実際に作ったものを触ってみて感じことは、アクションに対して即座に応答があるため、ネットワークを介した処理が成功したのか、そもそもリクエストがされたのかわかりづらいと思いました。 またエラーの際には「いいね」の値がアクション前の値に戻るのですが、その理由もこの実装だとユーザーには伝わりません。

その解決のためには「送信中」「送信成功」「送信失敗」などアクションに対するステータスをテキストやアイコン、背景色やテキスト色にしてあげるなどで補足するとよいと考えました。

Optimistic Update の利点としてはユーザーが API リクエストに対して心配をしなくてもいいことです。 送信中や送信成功時の表現は無しか控えめにし、対してエラー時は比較的明示的にユーザーへ表現してあげるくらいのバランスがよさそうです。

以下はエラー時にリクエストの結果を message として表示した実装を追加したものです。

<script setup lang="ts">
import { computed, ref } from 'vue'
import { sendLike } from '../modules/api'

const count = ref(0)
const isProcessing = ref(false)

const message = ref('')

const like = async () => {
  if (isProcessing.value) {
    return
  }
  try {
    isProcessing.value = true
    message.value = '' // リクエスト前にクリア

    const newCount = await sendLike()
    count.value = newCount

    // ここでリクエスト成功のさりげない表現もあり

  } catch (e) {
    console.error(e)
    message.value = '送信失敗'
  } finnally {
    isProcessing.value = false
  }
}
</script>

<template>
  <div>
    いいね数 : {{ countOptimistic }}
    <button @click="like">いいね</button>
    <div>{{ message }}</div>
  </div>
</template>

「いいね」の数は即座に反映されますが、それはあくまでも送信中でありエラーになる可能性があります。 エラーの際には「送信失敗」と表示し「いいね」の数が送信前の値に戻った理由が理解できるようになりました。

まとめ

Optimistic Update について調べたり実際に実装してわかったことのまとめです。

  • Optimistic Update はユーザーのアクションに対して結果の予測可能性が高い場合に取れる体感的な応答性の高いアプローチ
  • 保留中の UI としてはその他にもアプローチはあり、シナリオに応じて導入を検討する必要がある
  • 更新処理自体の実装は難しくないがエラー時の処理やステータス表示の補足などは必要
  • ナレッジやプラクティスの蓄積、カスタムフックなどの登場でさらに扱いやすいようになっていくと考えられる

おわりに

今後もこのような新しいアプローチの導入など技術的な貢献を通してクラウドサインの価値を高めていきたいです。 お読みいただき、ありがとうございました。

弁護士ドットコム Advent Calendar 2023の明日の担当は @metsa77 さんで、「Design systemまたはDesignOps軸で何か書く」みたいです! お楽しみに。