🤞

useOptimistic - 楽観的更新が与えるビジネスインパクト

2025/02/23に公開

はじめに

フロントエンド開発では楽観的更新というテクニックを使い、即時性のあるUXを提供していますが、実際にAPIのレスポンスタイムがビジネスにどのような影響を与えるのか調べてみました。
そこで今回はフロントエンド開発でよく耳にする楽観的更新の概要と、それらがもたらす影響について、ReactのuseOptimisticと共に解説していきたいと思います。

楽観的更新とは

楽観的更新(Optimistic Update)とは、ユーザーがアクションを行った際に、APIの処理完了を待たずに、入力された内容を即座に画面反映させる手法です。こうすることで、ユーザーは操作後すぐにフィードバック得られるため、快適なUXを提供することができます。

楽観的更新を実装するためには、Tanstack QuerySWRでも実装可能ですが、以下では、ReactのuseOptimisticを例として説明していきたいと思います。React の useOptimistic は、楽観的 UI 更新を簡単に適用するためのフックで、キャッシュ管理は行わず、コンポーネント内部の状態 (useState に近い) を楽観的に更新することができます。
以下の例では、楽観的更新なしパターンとありパターンのそれぞれの実装を確認し、両者の違いを説明していきたいと思います。

楽観的更新なし

まず、楽観的更新がない場合を見てみましょう。楽観的更新がない場合、ユーザーがボタンをクリックしてからサーバからの確認レスポンスを受け取るまでの間、UIが更新されずに待機状態となります。この結果、待機時間が長くなり、場合によっては離脱や再試行といった行動につながる可能性があります。

以下のイメージでは、メッセージを送信した瞬間、1秒後にメッセーが表示されていることが分かります。これはAPIのリクエストを行い、成功した場合にメッセージが画面へ表示されているため、遅延が発生してしまいます。

処理イメージ

実装方法

"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Loader2, Trash2 } from "lucide-react"

interface Todo {
  id: number
  text: string
  completed: boolean
}

export default function RegularTodo() {
  // 現在のTodoリストの状態
  const [todos, setTodos] = useState<Todo[]>([])
  // ロード状態の管理(追加処理中)
  const [isLoading, setIsLoading] = useState(false)
  // 削除中のTodoのID(ロード状態を示す)
  const [deletingId, setDeletingId] = useState<number | null>(null)

  // 新しいTodoを追加する関数
  async function addTodo(formData: FormData) {
    const text = formData.get("todo") as string
    if (!text) return // 空の入力を防ぐ

    setIsLoading(true) // ロード状態を開始

    const newTodo = {
      id: Date.now(), // 一時的なIDを生成
      text,
      completed: false,
    }

    // 実際のAPIリクエストをシミュレート(1秒遅延)
    await new Promise((resolve) => setTimeout(resolve, 1000))

    // Todoリストの状態を更新
    setTodos((prev) => [...prev, newTodo])
    setIsLoading(false) // ロード状態を解除
  }

  // Todoを削除する関数
  async function deleteTodo(id: number) {
    setDeletingId(id) // 削除中のIDを設定

    // 実際のAPIリクエストをシミュレート(1秒遅延)
    await new Promise((resolve) => setTimeout(resolve, 1000))

    // Todoリストの状態を更新(削除)
    setTodos((prev) => prev.filter((todo) => todo.id !== id))
    setDeletingId(null) // 削除処理の完了
  }

  return (
    <div className="w-full max-w-md mx-auto p-4 space-y-4">
      <h2 className="text-lg font-semibold">Todo List (without Optimistic Updates)</h2>
      
      {/* Todo追加用のフォーム */}
      <form action={addTodo} className="flex gap-2">
        <Input type="text" name="todo" placeholder="Add a new todo..." disabled={isLoading} />
        <Button type="submit" disabled={isLoading}>
          {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Add"}
        </Button>
      </form>
      
      {/* Todoリストの表示 */}
      <ul className="space-y-2">
        {todos.map((todo) => (
          <li key={todo.id} className="flex items-center justify-between p-3 bg-card rounded-lg border">
            <span>{todo.text}</span>
            {/* 削除ボタン */}
            <Button variant="ghost" size="icon" onClick={() => deleteTodo(todo.id)} disabled={deletingId === todo.id}>
              {deletingId === todo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
            </Button>
          </li>
        ))}
      </ul>
    </div>
  )
}

楽観的更新あり

一方、楽観的更新を採用している場合、たとえば「お気に入りに追加」や「いいね!」の操作後、実際のサーバーレスポンスを待つことなく、即座に画面上でアイコンの色変更やカウントの増加が表示されます。レスポンスが返ってきた場合、失敗時は更新(ロールバック)、成功した時は上書き(or 処理をパス)などを行うことで情報の一貫性を担保することができます。こうすることで即時性のあるUXを提供することが可能となり、ユーザーに対しスムーズな操作感を提供することができます。

以下のイメージでは、メッセージを送信した瞬間、即座にメッセージと末尾にsending…という文字が表示されます。APIのレスポンスを待っている間はsending…という文字が表示され、リクエストが成功すれば、sending…が削除され、オリジナルの文字が表示されます。楽観的更新なしのケースと比較すると、即座にテキストが表示されていることが分かります。

処理イメージ

実装方法

"use client"

import { useState, useTransition } from "react"
import { useOptimistic } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Trash2 } from "lucide-react"

interface Todo {
  id: number
  text: string
  completed: boolean
}

export default function OptimisticTodo() {
  // 現在のTodoリストの状態
  const [todos, setTodos] = useState<Todo[]>([])
  
  // 楽観的更新用の状態管理
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (state, newTodo: Todo[]) => newTodo)
  
  // 非同期処理の状態管理
  const [isPending, startTransition] = useTransition()

  // 新しいTodoを追加する関数
  async function addTodo(formData: FormData) {
    const text = formData.get("todo") as string
    if (!text) return // 空の入力を防ぐ

    const newTodo = {
      id: Date.now(), // 一時的なIDを生成
      text,
      completed: false,
    }

    // 楽観的にTodoを追加(即座にUI更新)
    addOptimisticTodo([...todos, newTodo])

    // 実際のAPIリクエスト(ここでは1秒の遅延をシミュレート)
    await new Promise((resolve) => setTimeout(resolve, 1000))

    // APIレスポンス後、実際の状態を更新
    setTodos((prev) => [...prev, newTodo])
  }

  // Todoを削除する関数
  async function deleteTodo(id: number) {
    startTransition(async () => {
      // 楽観的にTodoを削除(即座にUI更新)
      const filteredTodos = optimisticTodos.filter((todo) => todo.id !== id)
      addOptimisticTodo(filteredTodos)

      // 実際のAPIリクエスト(1秒の遅延をシミュレート)
      await new Promise((resolve) => setTimeout(resolve, 1000))

      // APIレスポンス後、実際の状態を更新
      setTodos((prev) => prev.filter((todo) => todo.id !== id))
    })
  }

  return (
    <div className="w-full max-w-md mx-auto p-4 space-y-4">
      <h2 className="text-lg font-semibold">Todo List (with Optimistic Updates)</h2>
      
      {/* Todo追加用のフォーム */}
      <form action={addTodo} className="flex gap-2">
        <Input type="text" name="todo" placeholder="Add a new todo..." />
        <Button type="submit">Add</Button>
      </form>
      
      {/* Todoリストの表示 */}
      <ul className="space-y-2">
        {optimisticTodos.map((todo) => {
          // APIレスポンスを待っているアイテムかどうかを判定
          const isSending = !todos.find((t) => t.id === todo.id)
          return (
            <li key={todo.id} className="flex items-center justify-between p-3 bg-card rounded-lg border">
              <div className="flex items-center gap-2">
                <span>{todo.text}</span>
                {isSending && <span className="text-sm text-muted-foreground italic">(sending...)</span>}
              </div>
              {/* 削除ボタン */}
              <Button variant="ghost" size="icon" onClick={() => deleteTodo(todo.id)} disabled={isPending}>
                <Trash2 className="h-4 w-4" />
              </Button>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

レスポンスタイムが与える影響

実際のところ、レスポンスタイムはユーザーにどのような影響を及ぼすのでしょうか?
ここでは、大きく 「離脱率」「満足度」 の2つの視点から解説します。

1. ユーザーの離脱

Webユーザーは、ページの読み込み時間が長いとすぐに離脱してしまう傾向があります。ウェブページの読み込みに対する許容度の調査では、情報検索の際、ユーザーが許容できる待機時間の閾値は約2秒という結果が示されています。一方で、フィードバック(ローディングアニメーションなど)が表示されると、ユーザーの許容時間が延びることも分かっています。
また、別の調査によると、40% のユーザーはロードに 3 秒以上かかるとサイトを離脱する というデータもあり、ページ速度の遅さが潜在的なユーザーを失う大きな要因になり得ることが示唆されています。

2. ユーザーの満足度

UXにおけるレスポンスタイムは、ユーザーの満足度やアプリケーションの継続利用に大きな影響を与えます。レスポンス時間とユーザー満足度の関連性に関する研究 によると、レスポンスタイムが長くなるほどユーザーの満足度は低下し、特に12秒を超えると許容できないと判断される傾向があることが明らかになっています。また、即時応答が必ずしもシステムの使いやすさや学習のしやすさを向上させるわけではないことも示されています。

具体的には、

  • レスポンスタイムが長くなると、ユーザーの不満が増加し、利用の継続をやめる可能性が高まる
  • 即時応答であっても、必ずしもシステムが「使いやすい」「学習しやすい」とは認識されない
  • レスポンスタイムの影響はアプリケーションの種類によって異なり、特に「任意利用型アプリ」(例:エンターテインメント系Webサービスなど)では、遅延の影響がより大きい

このように、Webページやアプリのレスポンスタイムはユーザーの満足度や継続利用意向に直接影響を与えることがわかりました。

UXの指標となる数値

次に、システムのレスポンスタイムの数値指標について確認したいと思います。NNgroupの記事によると、以下のような具体的な時間の違いがユーザー体験に大きく影響するとされています。

  • 0.1秒: ユーザーはほぼ即時にフィードバックを得られるため、操作が非常にスムーズであると感じ、違和感を感じることはほとんどない。
  • 1秒: 軽微な遅延として認識されるものの、ユーザーの注意を逸らすほどの影響は少なく、許容範囲内とされる。
  • 10秒: 明らかな遅延が発生し、ユーザーは待たされていると感じ、操作の中断や離脱につながる可能性が高い。

まとめ

UXの向上は、単なるデザインの改善だけでなく、ビジネス的なインパクトが大きいことが分かりました。特にユーザー獲得を目指していきたいエンターテイメント系 to C向けサービスではUXについて意識する必要がありそうです。

【参考文献】

Discussion