← ブログ

React のショッピングカート UX: 楽観的更新、元に戻す、補助単位で扱うお金

即座に反応し、正確さも保つ React のカート。補助単位で扱うお金、ロールバック付きの楽観的な数量更新、まとめたリクエスト、元に戻す、送料無料までの進捗。

Preet Suthar · · 6分で読めます

  • React
  • Next.js
  • E-commerce
  • UX

ショッピングカートは、ストアの中で即座に反応することと正確であることを同時に求められる最初の部品です。プラスを押したら、往復を待たずにすぐ数字が変わるべきです。でも在庫、価格、割引を握っているのはサーバーで、ときには「ノー」と言います。

ここでは、その両方を満たすカートを React と Next.js でどう作るかを紹介します。整数で保存するお金、きれいにロールバックする楽観的更新、まとめたリクエスト、確認ダイアログの代わりの元に戻す、そして嘘をつかない送料無料バーです。

まずはお金から

UI より先に、お金の保存方法を直してください。そうしないと、この先のすべての合計が少しずつずれます。

0.1 + 0.2

これは 0.30000000000000004 になり、カートはたくさんの数字を足します。金額は通貨の最小単位の整数で保存します。USD ならセント、補助単位のない JPY なら円です。これは Stripe をはじめほとんどの決済 API が送ってきて、受け付ける形でもあります。

type Money = { amount: number; currency: string }

function addMoney(a: Money, b: Money): Money {
  if (a.currency !== b.currency) {
    throw new Error(`Can't add ${a.currency} to ${b.currency}`)
  }
  return { amount: a.amount + b.amount, currency: a.currency }
}

function formatMoney(money: Money, locale: string) {
  const format = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: money.currency,
  })
  const digits = format.resolvedOptions().maximumFractionDigits ?? 2
  return format.format(money.amount / 10 ** digits)
}

formatMoney({ amount: 4800, currency: "USD" }, "en-US") は $48.00 になり、同じ金額を JPY にすると ¥4,800 になります。Intl.NumberFormat は記号、桁区切り、小数をロケールごとに扱うので、ドイツの 48,00 € も手間なしで得られます。決済プロバイダーには補助単位が ISO 標準と異なる通貨がいくつかあるので、一度プロバイダーの一覧を確認してください。

2 つの通貨を足すのを拒否するのは心配しすぎではありません。EUR のカートに USD の送料が入ってくるバグを捕まえてくれます。

ロールバックできる楽観的更新

誰かがプラスを押した瞬間に、カートは変わるべきです。数量、行の合計、小計です。リクエストはその裏で走ります。失敗したら、行はサーバーが最後に確定した数量に戻り、行の下に理由を表示します。たとえば「Sand / Large は残り 2 点です」のように。

React 19 の useOptimistic はこれにぴったりです。楽観的な値はトランジションの実行中に表示され、トランジションが終わると React がそれを破棄するので、失敗時のロールバックに追加のコードは要りません。

const [cart, setCart] = useState(initialCart)
const [shownCart, showChange] = useOptimistic(cart, applyLineChange)

function changeQuantity(lineId: string, quantity: number) {
  startTransition(async () => {
    showChange({ lineId, quantity })
    const result = await updateLine(lineId, quantity)
    startTransition(() => {
      if (result.ok) setCart(result.cart)
      else setLineError(lineId, result.message)
    })
  })
}

正直さを保つルールは 2 つです。サーバーは変更のたびにカート全体を返し、そのカートで自分のカートを置き換えます。そうすれば合計、割引、送料は常に 1 か所から来ます。そして行のエラーは、買い物客がその行をもう一度変更するまで残します。読まれる前に消えるメッセージは、メッセージがないのと同じだからです。

Next.js では、updateLine を server action にできます。Next.js は本番環境で server action が throw したエラーメッセージを隠すので、表示したいエラーは throw せずに { ok: false, message } を返します。

連続した押下をまとめる

何かを 5 個欲しい人は、1 秒のうちにプラスを 4 回押します。リクエストを 4 回送ると、失敗の機会が 4 回生まれ、順不同で届きうるレスポンスが 4 つ返ってきます。代わりに最終的な数量で 1 回だけリクエストを送ります。

const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())

function pressQuantity(lineId: string, quantity: number) {
  showLocally(lineId, quantity)
  clearTimeout(timers.current.get(lineId))
  timers.current.set(
    lineId,
    setTimeout(() => commitQuantity(lineId, quantity), 400)
  )
}

「プラス 1」ではなく、常に絶対的な数量を送ります。絶対値なら再試行しても遅れて届いても安全です。同じ行への 2 つのリクエストが重なった場合は、最後に送ったものより古いレスポンスを無視します。

「本当によろしいですか?」の代わりに元に戻す

削除時の確認ダイアログは、まれなミスを防ぐためにすべての削除に 1 ステップを追加します。元に戻すは同じミスを防ぎつつ、ミスがなければコストはゼロです。

削除した行を約 5 秒間「Ring Vase を削除しました · 元に戻す」という短い行に畳み、下の行を上にスライドさせて詰めます。元に戻すと、行は一番下ではなく元の位置に戻ります。サーバーからはすぐに削除して元に戻すときに追加し直すこともできますし、行が消えるまで待つこともできます。すぐに削除すればタブが閉じられてもサーバーのカートは正しいままなので、私は元の位置をクライアントに保持したうえでこちらを選んでいます。

削除は polite なライブリージョンで読み上げ、フォーカスはページに戻すのではなく、「元に戻す」ボタンや次の行など妥当な場所に置きます。

古くなった行

カートはタブの中で何日も放置されることがあります。再び読み込まれたら、サーバーは追加後に価格が変わった行(「$58 に値下がりしました」)と売り切れた行に印を付けるべきです。売り切れの行は削除されるまでチェックアウトをブロックし、その理由はトーストではなく行そのものに表示します。

本当のことを言う送料無料バー

「あと $12.00 で送料無料」は、人に実際に好かれる数少ないカートのメッセージです。正しくやるべき点は 2 つあります。

  • サーバーと同じ小計を使う。 送料無料が割引後に計算されるなら、バーもそうでなければなりません。さもないと、バーは条件を満たしていると言うのにチェックアウトで送料がかかります。
  • お祝いは一度だけ。 しきい値を超えたときに短い演出を入れ、その後は控えめに「送料無料になります」と表示します。レンダリングのたびやドロワーを開くたびに繰り返さないでください。

テキストラベル付きの本物の progress 要素を使い、バーがただの色付きの帯ではなく「$150 中 $138」と読み上げられるようにします。

カートに追加ボタンとバッジ

買い物客がすべての商品ページで目にする小さなこと:

  • ボタンはラベルからスピナー、チェックへと変わる間も幅を保つので、隣のものが跳ねることはありません。
  • スピナーは表示されるまで約 300ms 待ちます。ほとんどの追加はそれより速く終わるので、スピナーは不要です。
  • カートのバッジは変わった桁だけを変え、ドロワーが開くか短い確認が表示されるので、商品がどこに行ったのかがわかります。

チェックリスト

  • お金は補助単位の整数で持ち、Intl.NumberFormat でフォーマットする。
  • 2 つの通貨を決して足さない。
  • 楽観的な数量更新。失敗したら最後に確定したカートにロールバックし、行にメッセージを表示する。
  • 連続した押下を、最終的な数量を送る 1 つのリクエストにまとめる。
  • 削除後約 5 秒間は元に戻せて、行の位置も復元する。
  • 古い価格や売り切れの行は行に表示し、必要ならチェックアウトをブロックする。
  • 送料無料までの進捗はサーバーと同じ小計から。

これを実装した HextaUI のブロック

これらが HextaUI Pro の Cart ブロックの裏にあるルールです。AddToCartButton、ライブで個数を表示する CartTrigger、デスクトップでは右から、スマートフォンでは下からスライドインする CartDrawer、そしてカート専用ページ向けの CartPanel があります。連続した押下は 1 つのリクエストにまとめられ、コールバックが失敗すると行がロールバックしてあなたのメッセージを表示し、削除した行は 5 秒間「元に戻す」の行に畳まれます。同じロジックは自分のレイアウト用に useOptimisticCart としてエクスポートされています。例は Shopify Storefront API と Next.js の server actions をカバーしています。

Order Summary はチェックアウト向けに同じ形のカートを受け取り、割引コード、税の見積もり、価格が変わった行を扱います。どちらも Price のお金のヘルパーを使っています。これは整数の補助単位で計算し、2 つの通貨を足すことを拒否します。どれもライブプレビューで試せます。

カートができたら、次はチェックアウトです: React と Next.js で作る 1 ページチェックアウト。

よくある質問

価格は浮動小数点数で保存すべきですか? いいえ。通貨の補助単位の整数で保存し、割り算はフォーマットするときだけにします。

カートの更新は楽観的にすべきですか? 数量と削除についてははい。ただし、変更のたびにサーバーのカートで自分のカートを置き換え、失敗したら理由とともにロールバックすることが条件です。

商品を削除するときは元に戻すと確認ダイアログのどちらが良いですか? 元に戻すです。本当に削除したいときは速く、そうでないときも同じくらい安全です。

プラスを押すたびにリクエストを送らないようにするには? 最後に押されてから数百ミリ秒待ち、最終的な数量を送ります。