HB DevTools

Knowledge

useTransition を複数使って独立したローディング状態を管理するパターン

同一画面に種類の異なる非同期処理が複数ある場合、useTransition を共有すると状態が混在します。用途ごとに独立した useTransition を宣言し、isBusy フラグで相互排他制御する設計パターンを解説します。

ReactuseTransitionパターン

1. 問題:1つの useTransition を共有するとローディングが混在する

Markdown Formatter では、1つの画面に2つの非同期処理があります。

  • 「整形する」ボタン: Prettier による Markdown 整形(クライアントサイド非同期)
  • 「MD変換」ボタン: Gemini API を呼び出す Server Action(サーバーサイド非同期)

当初、1つの useTransition を両方の処理で使い回す実装を検討しました。

// NG: 1つの useTransition を共有する
const [isPending, startTransition] = useTransition()

function handleFormat() {
  startTransition(async () => { /* Prettier 整形 */ })
}

function handleAiFormat() {
  startTransition(async () => { /* Gemini API 呼び出し */ })
}

この実装では isPendingtrue の間、 両方のボタンを disabled={isPending} として非活性にすると、「整形する」を押している間は「MD変換」も使えないという挙動になります。 2つは独立した処理なので、片方が処理中でも他方は別途操作できるべきです。 また、ローディングインジケーターの表示でも「どちらが処理中か」が判別できなくなります。

2. 解決:useTransition を用途ごとに独立させる

useTransition を処理ごとに別々に宣言することで、 それぞれ独立したローディング状態を持つことができます。 React の useTransition はフック単位でペンディング状態を追跡するため、 複数個使っても問題ありません。

// OK: useTransition を処理ごとに独立して使う
const [isPending, startTransition] = useTransition()       // Prettier 整形用
const [isAiPending, startAiTransition] = useTransition()   // AI 変換用

こうすることで isPending は「整形する」の処理中のみ trueisAiPending は「MD変換」の処理中のみ true になります。 両者が干渉しないため、各ボタンのローディングラベルやアイコンも それぞれの状態に基づいて正確に制御できます。

3. 実装例

実際の MarkdownFormatterClient.tsx での実装は以下のとおりです。

// Prettier 整形用トランジション
const [isPending, startTransition] = useTransition()
// AI 整形用トランジション(別トランジションで独立したローディング管理)
const [isAiPending, startAiTransition] = useTransition()

// 「整形する」ボタン
<Button disabled={!input.trim() || isBusy}>
  {isPending ? <Loader2 className='animate-spin' /> : <FileCode2 />}
  {isPending ? '整形中…' : '整形する'}
</Button>

// 「MD変換」ボタン
<Button disabled={!input.trim() || isBusy || isRateLimited}>
  {isAiPending ? <Loader2 className='animate-spin' /> : <Sparkles />}
  {isAiPending ? 'MD変換中…' : 'MD変換'}
</Button>

各ボタンのラベル・アイコン・disabled 制御をそれぞれ独立したisPending / isAiPending で行うことで、 「整形する」を押した時だけ「整形中…」に変わり、 「MD変換」は独立してローディング状態を持てます。

4. isBusy フラグによる相互ブロック

2つのトランジションを独立させた上で、いずれかが処理中の場合はもう一方のボタンも使えないようにしたいという要件があります。 Prettier 整形中に AI 変換を並行実行すると結果の上書き競合が起きるためです。

const isBusy = isPending || isAiPending

// 両ボタンとも isBusy を disabled 条件に含める
<Button disabled={!input.trim() || isBusy}>整形する</Button>
<Button disabled={!input.trim() || isBusy || isRateLimited}>MD変換</Button>

この設計により:

  • 「整形する」が処理中 → isBusy = true → 「MD変換」も非活性
  • 「MD変換」が処理中 → isBusy = true → 「整形する」も非活性
  • いずれも処理中でない → isBusy = false → 両ボタン活性

isBusy は相互排他の制御として使い、 個別のローディング表示はそれぞれの isPending / isAiPending で行うという 役割分担が明確になります。

5. 適用場面と注意点

  • 適用が有効な場面: 同一コンポーネントに「種類の異なる非同期処理」が複数あり、 それぞれ独立したローディング状態とフィードバックが必要な場合。 例: 保存ボタンと削除ボタン、整形と変換、検索と絞り込みなど。
  • 共有で十分な場面: 複数の非同期処理が概念的に同一の「送信中」状態を表す場合は、 1つの useTransition で十分です。 例: フォームに複数フィールドがあるが「送信」は1つだけの場合。
  • useTransition のスコープ:startTransition で開始した非同期処理は 「低優先度の状態更新」としてマークされ、 React がより優先度の高い更新(ユーザー入力等)を先に処理できるようになります。 ローディング中もUIがブロックされずインタラクティブな状態を維持できる点がuseState による手動フラグ管理との大きな違いです。