🏓

SWRでdebounceしつつ、値が変更されたら即座に以前の結果を表示しないようにする

2023/03/28に公開

SWRにおいて、下記の要件を満たすようなことをしたいとき少し困った

  • 入力についてdebounceやthrottleで抑止したい
  • 入力値が変わったら、前回の値は表示しないようにしたい

前回の値が表示されないようにしたいケースはそれほど多くないが、例えば金額を取り扱うような場合で、混乱を起こしてしまうようなケースを想像してもらうとわかりやすいだろう。

今回は入力値のフィボナッチ数が支払額になるような入力フィールドを例にしてみる

事前準備:素直に作るとき(Debounceしない)

/api/fibというフィボナッチ数を返すAPIを素直理に利用するとこのような具合になる。apiCallCountについては後ほどdebounceが正しくされているかを見るためのデバッグ用の値である

const useFibonacci = (value: number) => {
  const [apiCallCount, setApiCallCount] = useState(0)
  const result = useSWR(["fibonacch", value], async () => {
    const response = await fetch(`/api/fib?value=${value}`)
    const json = await response.json()
    setApiCallCount(num => num + 1)
    return json.fib
  }, {
    keepPreviousData: false
  })
  return {
    result,
    apiCallCount
  }
}

また、結果表示コンポーネントは下記のようにしておく

const FibonacchResult: FC<{ value: number, isLoading: boolean, fibResult: number, apiCallCount: number }> = ({ value, isLoading, fibResult, apiCallCount }) => {
  if (isLoading) {
    return <div>
      <div>fib({value}) = Loading...</div>
      <div>API Call: {apiCallCount}</div>
    </div>
  }

  return <div>
    <div>fib({value}) = {fibResult}</div>
    <div>API Call: {apiCallCount}</div>
  </div>
}

結果に「円」をつけてるのはユースケースを想像しやすいようにするためだけに行っている。

あとは下記のように組み合わせる。

const NoDebounceFibonacch: FC<{ value: number }> = ({ value }) => {
  const { result, apiCallCount } = useFibonacci(value)
  return <FibonacchResult value={value}
    isLoading={result.isLoading}
    fibResult={result.data} apiCallCount={apiCallCount}
  />
}

下記のようにdebounceされずに処理されている様子がわかる

Debounce

ここからdebounceを組み合わせていく。
debounce処理についてはusehooks-tsのものを利用する

1. 普通にDebounceする

debounceを普通に組み合わせると下記のようになる。

const useFibonacciWithDebounce1 = (value: number) => {
  const [apiCallCount, setApiCallCount] = useState(0)
  const debounceValue = useDebounce(value)
  const result = useSWR(["fibonacch_debounce_1", debounceValue], async () => {
    const response = await fetch(`/api/fib?value=${debounceValue}`)
    const json = await response.json()
    setApiCallCount(num => num + 1)
    return json.fib
  }, {
    keepPreviousData: false
  })
  return {
    result,
    apiCallCount
  }
}

ただ、この状態だとkeepPreviousData:falseを設定していてもdebounceValue自体が変化するまでの間、前の結果が残ってしまうため今回の要件だと不足してしまう。

検索結果のサジェストなどのユースケースならここまでで十分な場合が多いが、今回はこの部分を対処したいので、もう少し調整が必要になる

2. Debounceされたら以前のデータを表示しないようにする

値が変化した場合に即座に値を消すために、debounce中であればキーをnullにする

const useFibonacciWithDebounce2 = (value: number) => {
  const [apiCallCount, setApiCallCount] = useState(0)
  const debounceValue = useDebounce(value)
  const isDebouncing = debounceValue !== value
  const result = useSWR(isDebouncing ? null : ["fibonacch_debounce_2", debounceValue],
    async () => {
      const response = await fetch(`/api/fib?value=${debounceValue}`)
      const json = await response.json()
      setApiCallCount(num => num + 1)
      return json.fib
    }, {
    keepPreviousData: false
  })
  return {
    result,
    apiCallCount
  }
}

フラグでnullを渡す手法についてはこちらのissueなどでも類似の例が取り上げられている。

ただこの場合、データが無いかつローディングが始まってない(data:undefindかつisLoading:false)という状態が一瞬発生してしまうようだった。

詳細は調べきれなかったが、内部処理中に何らかのラグが起きていると考えられる。

3. Debounceしつつ、Loadingの表示を意図どおりにする

一瞬何も表示されない部分については、dataisLoadingを組み合わせた状態から新たにloading状態を作れば解決可能そうだった。

const WithDebounceFibonacch3: FC<{ value: number }> = ({ value }) => {
  const { result, apiCallCount } = useFibonacciWithDebounce2(value)
  const isLoading = result.isLoading || result.data === undefined
  return <FibonacchResult value={value}
    isLoading={isLoading}
    fibResult={result.data} apiCallCount={apiCallCount}
  />
}

hooksの内部まとめたり、useSWRの結果を上書きする形でインターフェースを揃えるようなやり方も存在するが、今回はそこまではしないこととした

まとめ

最後にここまで作ったものを同時に並べるとこのようになる。

GitHubで編集を提案

Discussion