ボタン連打でP2002が出たので、debounceをやめてuseRefで二重送信を防ぐ

決済ボタンを連打すると/api/v1/checkoutにPOSTが2本飛んでしまって、DB側で

PrismaClientKnownRequestError: Unique constraint failed on the fields: (idempotency_key, order_id)

が出てしまいました。Networkタブを見ると、ほぼ同じタイムスタンプで12ms差のPOSTが2本。片方は200 OKで、もう片方がStatus 500、P2002エラーが返ってきました。サーバーログにも同じキーとorder_idで2本目が落ちた記録がちゃんと残ってます。

サーバー側のユニーク制約は最後の防波堤としてはいいのですが、クライアント起因の二重送信を毎回DBエラーで受け止めるのは無駄なことなので、クライアント側で確実に止めたいです。

debounce流用が失敗

連打を止めるなら間引きかなと思って、手元で動いていた検索サジェスト用のdebounce実装をそのまま流用してみることにしました。

最初はlodashのdebounceでhandleSubmitを包みました。

const debouncedSubmit = debounce(handleSubmit, 500)

これをonClickに直接渡すんですが、コンポーネントが再レンダリングされるたびにdebounceインスタンスが再生成されて、内部タイマーがリセットされるのです。このフォームはカード名義やクーポンのバリデーション結果が親stateに上がるたびに再描画が走る作りでした。コンソールで確認すると、2回クリックして2回ともPOSTが実行されてる。debounceのタイマーが毎レンダリングで作り直されていたわけです。

そこでuseCallbackで包みます。

const debouncedSubmit = useCallback(
  debounce(async () => {
    await mutateAsync(payload)
  }, 500),
  []
)

しかもlodashのデフォルトはtrailing: trueなので、クリックしてから500ms待たされてからAPIリクエストが発火。押した瞬間に何も起きないのは使えなさすぎます。それにESLintのexhaustive-depsにも引っかかる。debounceの戻り値関数をそのままuseCallbackに入れると、依存配列の追跡が壊れてしまうのです。

leading指定でも足りない

即時発火させるためにオプションを変えました。

const debouncedSubmit = useCallback(
  debounce(async () => {
    await mutateAsync(payload)
  }, 500, { leading: true, trailing: false }),
  [payload]
)

初回クリックは即時発火するようになったんですけど、payloadが変わるたびにdebounceインスタンスが再生成される問題は残ります。さらに開発環境でThrottleをSlow 3Gにしてネットワークレイテンシを600ms程度にすると、500msのdebounceウィンドウが切れた直後に2回目のクリックが可能になって、重複リクエストが通ってしまうのです。

タイマーとサーバーの処理時間は連動していないので、そもそもdebounceでやる話じゃなかったです。debounceはやめて、通信が終わるまでロックする方式に切り替えました。

stateガードの限界

まずはuseStateでisSubmittingを管理する形に。

const [isSubmitting, setIsSubmitting] = useState(false)

const handleClick = async () => {
  if (isSubmitting) return
  setIsSubmitting(true)
  try {
    await api.post('/checkout', data)
  } finally {
    setIsSubmitting(false)
  }
}

<button disabled={isSubmitting} onClick={handleClick}>購入する</button>

通常の連打ならこれで止まります。だけどstateでのガードは同じイベントループ内で連続して呼ばれるとすり抜けることがあるのです。setIsSubmitting(true)を呼び出しても、同じレンダーで作られたhandleClickのクロージャーではisSubmittingはまだfalseのまま。検証用にbutton.click()を2回続けて叩くと、if (isSubmitting) returnを両方すり抜けてしまいました。

performance.now()で見ると、こんなログでした。

click 1204.3
click 1204.8

0.5ms差で2回関数に入ってます。実際の連打ではここまで詰むことはないので、検証用に同期的にclick()を叩いた結果ではあるんですけど、stateの更新が次のレンダリングに反映される前に2回目が評価される余地があるという点は同じです。ここはstateではなく、同期的に書き換わるrefでガードする必要があったみたいです。

const isSubmittingRef = useRef(false)
const [isSubmitting, setIsSubmitting] = useState(false)

const handleClick = async () => {
  if (isSubmittingRef.current) return
  isSubmittingRef.current = true
  setIsSubmitting(true)

  try {
    await api.post('/checkout', data)
  } catch (err) {
    if (axios.isAxiosError(err)) {
      console.error(err.response?.data)
    }
  } finally {
    isSubmittingRef.current = false
    setIsSubmitting(false)
  }
}

refは即時書き換わるので、0.5ms差の2回目呼び出しも確実に弾くことができます。でもこれだけだとDOMのbutton要素自体のpointer-eventsは有効なままなので、押した感が残るし、キーボード操作でも反応してしまいます。見た目も動きもどっちも止めたいので、HTML側もきちんと塞いでおきます。

DOM側の塞ぎ方

disabled属性を付けて、同時にCSSでもpointer-events: noneを設定しました。

button:disabled {
  pointer-events: none;
  opacity: 0.6;
  cursor: not-allowed;
}

pointer-events: noneも一応入れてます。disabledでクリックは飛ばないはずですけど、子要素にイベントが漏れる可能性もあるので、念のため塞いでおきます。

キーボード連打も一応確認しました。buttonのdisabled={isSubmitting}が効いていれば、ブラウザ側でkeydown -> clickの合成は止まる仕様です。HTMLButtonElement.disabled = trueにしてEnter連打を試しても、2回目は入りませんでした。

だけどフォームの内側にボタンを置いていたせいで、buttonのonClickとformのonSubmitの両方から同じハンドラが叩かれていました。ロックを入れても送信経路が2本あると意味がないので、type="button"を指定して1本に絞りました。これでロックが効かないという抜け道はなくなったはずです。

ロック処理をフックにまとめる

実は前からidempotency_keyはリクエストに乗せていて、冒頭のエラーがそれです。サーバー側でそのキーを見て重複を弾く処理が入ってなかったのが問題でした。2本目もそのままDBまで到達して、ユニーク制約でようやく止まってました。クライアント側のロックで2本目を出さないのがメインの対策になるんですけど、一応キーの方も見直しておきます。ついでにuuidパッケージではなく標準のcrypto.randomUUID()にしました。v4だけなら依存をひとつ減らせる程度の差ですけど残す理由もないので。

const idempotencyKeyRef = useRef<string>(crypto.randomUUID())

await api.post('/checkout', payload, {
  headers: { 'Idempotency-Key': idempotencyKeyRef.current },
})

キーの再生成ルールは、成功時と4xxの修正再送時は新規、5xxやタイムアウト時は同じキーでリトライ、という形でステータスコードごとに分けてます。4xxでもボディが変わる再送に同じキーを使うと422が返るので、そこだけ注意です。実際の分岐はこんな感じです。

function shouldRenewKey(status?: number, isRetryWithModifiedBody = false) {
  if (status === undefined) return false // タイムアウト
  if (status >= 500) return false
  if (status >= 400 && status < 500) return isRetryWithModifiedBody
  return true
}

共通化のためにカスタムフックに切り出しました。

function useAsyncLock<T extends (...args: any[]) => Promise<any>>(asyncFn: T) {
  const [isPending, setIsPending] = useState(false)
  const lockRef = useRef(false)
  const fnRef = useRef(asyncFn)

  useEffect(() => {
    fnRef.current = asyncFn
  })

  const execute = useCallback(
    async (...args: Parameters<T>): Promise<Awaited<ReturnType<T>> | undefined> => {
      if (lockRef.current) return undefined
      lockRef.current = true
      setIsPending(true)

      try {
        return await fnRef.current(...args)
      } finally {
        lockRef.current = false
        setIsPending(false)
      }
    },
    []
  )

  return { execute, isPending }
}

asyncFnはレンダリングごとに関数参照が変わるので、useCallbackの依存に入れると再生成されちゃいます。なのでuseCallbackのラッパー内ではなく、useRefで最新の関数を保持する形にしました。execute内ではfnRef.current(...args)を呼ぶだけです。

ボタン側の実装はこうなりました。

const { execute: onSubmit, isPending } = useAsyncLock(async () => {
  await api.post('/checkout', payload, {
    headers: { 'Idempotency-Key': idempotencyKeyRef.current },
  })
})

<button type="button" disabled={isPending} onClick={onSubmit}>
  {isPending ? <Spinner /> : '確定'}
</button>

Refロックとdisabled、サーバー側の冪等性キーを入れてから、クライアント起点の二重送信はほぼ出なくなりました。lockRef.currentをtrueにするタイミングが大事で、少しでもズレると防げなくなります。ボタン側は一旦これで様子を見てます。