React 17でuseTransition (isPending, startTransition)を使って読込中と二重クリック防止を実装する方法

useTransitionとは

useTransitionはUIの一部をバックグラウンドでレンダリングするためのReactフックです。

useTransitionは常に以下の2つの要素を含む配列を返します。

  1. トランジションが保留中(処理中)であるかを示す isPending フラグは、読み込み中の表示やボタンの二重クリック防止によく使用されます。
  2. 更新をトランジションとしてマークするための startTransition 関数
App.tsx
import { useTransition } from 'react'

function App() {
  const [isPending, startTransition] = useTransition()
  // ...
}

トランジションが保留中(処理中)であるかどうかを示す isPending フラグは、読み込み中の表示および二重クリック防止のためによく使用されます。

読み込み中および二重クリック防止の実装

例として、useStateを使用してボタンをクリックすると「読み込み中」と表示され、その間はボタンをクリックできない二重クリック防止のコードがあったとします。

App.tsx
import { useState } from 'react'

function App() {
  const [result, setResult] = useState(null)
  const [isLoading, setIsLoading] = useState(false)

  const submitAction = async () => {
    setIsLoading(true)
    setResult(null)
    try {
      const res = await fetch('https://dummyjson.com/test?delay=2000')
      if (!res.ok) {
        throw new Error(`HTTPエラー: ${res.status}`)
      }
      const data = await res.json()
      setResult(data)
    } catch (err: unknown) {
      const message =
        err instanceof Error ? err.message : 'エラーが発生しました'
      throw new Error(message)
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <>
      <h1>Reactで読込中&二重クリック防止サンプル(useState版)</h1>
      <button disabled={isLoading} onClick={submitAction}>
        データを取得
      </button>
      {isLoading && <p>読み込み中...</p>}
      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </>
  )
}

export default App

Reactで読込中&二重クリック防止サンプル(useState版)

useTransitionを使用する場合は、isPendingとstartTransitionを使用して以下のようになります。

setResult(null) はstartTransitionの中で処理するとリクエスト前にリセットできないので、startTransitionの前に実行しています。

App.tsx
import { useState, useTransition } from 'react'

function App() {
  const [result, setResult] = useState(null)
  const [isPending, startTransition] = useTransition()

  const submitAction = async () => {
    const res = await fetch('https://dummyjson.com/test?delay=2000')
    if (!res.ok) {
      throw new Error(`HTTPエラー: ${res.status}`)
    }
    const data = await res.json()
    setResult(data)
  }

  return (
    <>
      <h1>Reactで読込中&二重クリック防止サンプル(useTransition版)</h1>
      <button
        disabled={isPending}
        onClick={async () => {
          setResult(null)
          startTransition(async () => {
            await submitAction()
          })
        }}
      >
        データを取得
      </button>
      {isPending && <p>読み込み中...</p>}
      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </>
  )
}

export default App

Reactで読込中&二重クリック防止サンプル(useTransition版)

ご覧の通り、useTransitionを使用するとonClickのコードは少し増えますが、submitActionのコードが減るため、全体的にはスッキリします。

また、useTransitionを使わずにuseStateでtrueとfalseを書いて制御する方法だと、以下のような問題が発生するため、現在はuseStateではなくuseTransitionの使用が推奨されています。

useStateでtrueとfalseを書いて制御する問題点

  • isLoadingのtrue, falseを明示的にすべての分岐に書かねばならず、try/catch/finally構文が必須になる
  • コードが複雑になると、trueとfalseの対応がズレてバグの原因になる
  • 重たい処理だとフリーズする
  • 応答性を考慮した低優先の処理の制御ができない

しかし、React 17以下ではuseTransitionは使用できないので、代わりに疑似useTransition (useFakeTransition)のカスタムフックを作成して、isPendingとstartTransitionを使用した「読み込み中」と「二重クリック防止」を実装する必要があります。

※ 疑似useTransition (useFakeTransition)は重たい処理のフリーズ回避と低優先の処理の制御はできません。

疑似useTransitionのカスタムフックの作成方法

最初に疑似useTransition (useFakeTransition)のカスタムフックを作成します。

useFakeTransition.tsx
import { useState, useCallback } from 'react'

type StartTransition = (fn: () => Promise<void>) => Promise<void>

export const useFakeTransition = (): [boolean, StartTransition] => {
  const [isPending, setIsPending] = useState(false)

  const startTransition: StartTransition = useCallback(async (fn) => {
    setIsPending(true)
    try {
      await fn()
    } catch (err: unknown) {
      const message =
        err instanceof Error ? err.message : 'エラーが発生しました'
      throw new Error(message)
    } finally {
      setIsPending(false)
    }
  }, [])

  return [isPending, startTransition]
}

useTransitionと同じようにisPendingとstartTransitionを返していることがコードから確認できます。

あとはuseTransitionのときのようにimportでisPendingとstartTransitionを読み込めば使用可能です。

App.tsx
import { useState } from 'react'
import { useFakeTransition } from './useFakeTransition'

function App() {
  const [result, setResult] = useState(null)
  const [isPending, startTransition] = useFakeTransition()

  const submitAction = async () => {
    setResult(null)
    const res = await fetch('https://dummyjson.com/test?delay=2000')
    if (!res.ok) {
      throw new Error(`HTTPエラー: ${res.status}`)
    }
    const data = await res.json()
    setResult(data)
  }

  return (
    <>
      <h1>Reactで読込中&二重クリック防止サンプル(useFakeTransition版)</h1>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(async () => {
            await submitAction()
          })
        }}
      >
        データを取得
      </button>
      {isPending && <p>読み込み中...</p>}
      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </>
  )
}

export default App

ちなみに疑似useTransition (useFakeTransition)だとsetResult(null)の実行タイミングがuseTransitionとは異なるため、submitActionの関数内に書いてあります。

Reactで読込中&二重クリック防止サンプル(useFakeTransition版)