はじめに
React 18で追加されたuseTransitionは、UIの応答性を保つためのフックです。
「ボタンを押したのに画面が固まる」「フィルターを切り替えたら一瞬フリーズする」こうした体験を改善できます。
この記事では、useTransitionの基本的な仕組みから、Next.js App Routerでの使い方をメモとして残します。
対象読者
- Reactの基本的なフック(
useState,useEffect)を使ったことがある方 -
useTransitionを聞いたことはあるけど使ったことがない方 - Next.js App Routerを使っている方
環境
- React 18以降
- Next.js 14(App Router)
- TypeScript
useTransitionの基本
stateの更新には優先度がある
通常、Reactではstateの更新はすべて同じ優先度で処理されます。例えば、ボタンをクリックして2つのstateを更新する場合、Reactは両方の更新が完了するまでUIを再レンダリングしません。
ここで問題になるのが、重い処理を含むstate更新です。
const handleClick = () => {
setInputValue(value); // 軽い処理(入力欄の更新)
setFilteredList(heavyCalc(value)); // 重い処理(大量データのフィルタリング)
};
この場合、heavyCalcが完了するまで入力欄の更新も反映されず、ユーザーには「フリーズした」ように見えます。
useTransitionで優先度を分ける
useTransitionを使うと、state更新を「緊急(urgent)」と「非緊急(transition)」に分けられます。
import { useState, useTransition } from 'react';
const [isPending, startTransition] = useTransition();
useTransitionは配列で2つの値を返します。
第1引数:isPending(boolean)
startTransitionで包んだ更新が処理中かどうかを示すフラグです。
トランジション中はtrueになり、完了するとfalseに戻ります。
この値を使ってローディング表示やボタンの非活性化ができます。
第2引数:startTransition(関数)
state更新を「非緊急」としてマークするための関数です。
この関数に渡したコールバックの中にstate更新を書くと、Reactはそれを後回しにして、他の緊急な更新を先に処理します。
第1引数のisPendingで「今トランジション中?」を判定し、第2引数のstartTransitionで「この更新は後回しにしていいよ」とReactに伝える、という役割分担です。
const handleClick = () => {
setInputValue(value); // 緊急: すぐに反映される
startTransition(() => {
setFilteredList(heavyCalc(value)); // 非緊急: 後回しにしてOK
});
};
こうすると、入力欄は即座に更新され、フィルタリング結果は裏側で処理されます。処理中はisPendingがtrueになるので、ローディング表示を出すこともできます。
シンプルな使用例
タブの切り替えで大量のリストをフィルタリングするケースを考えてみましょう。
useTransitionなしの場合
'use client';
import { useState } from 'react';
export default function FilterTabs({ items }: { items: Item[] }) {
const [tab, setTab] = useState<'all' | 'active'>('all');
const [filteredItems, setFilteredItems] = useState(items);
const handleTabChange = (newTab: 'all' | 'active') => {
setTab(newTab);
// 大量データのフィルタリング(重い処理)
const result = items.filter((item) =>
newTab === 'all' ? true : item.isActive
);
setFilteredItems(result);
};
return (
<div>
<button onClick={() => handleTabChange('all')}>すべて</button>
<button onClick={() => handleTabChange('active')}>アクティブ</button>
{/* filteredItemsの描画... */}
</div>
);
}
itemsが数千件あると、タブを押してからUIが反映されるまでにラグが出ます。
useTransitionありの場合
'use client';
import { useState, useTransition } from 'react';
export default function FilterTabs({ items }: { items: Item[] }) {
const [tab, setTab] = useState<'all' | 'active'>('all');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
const handleTabChange = (newTab: 'all' | 'active') => {
setTab(newTab); // 緊急: タブのハイライトはすぐ切り替わる
startTransition(() => {
// 非緊急: フィルタリング結果は後から反映される
const result = items.filter((item) =>
newTab === 'all' ? true : item.isActive
);
setFilteredItems(result);
});
};
return (
<div>
<button onClick={() => handleTabChange('all')}>すべて</button>
<button onClick={() => handleTabChange('active')}>アクティブ</button>
{isPending && <p>読み込み中...</p>}
{/* filteredItemsの描画... */}
</div>
);
}
変更点は3つだけです。
-
useTransition()を呼ぶ - 重い更新を
startTransition()で包む -
isPendingでローディング表示を出す
これでタブのハイライトは即座に切り替わり、リストの更新は裏側で行われるようになります。
Next.js App Routerのrouter操作で使う
なぜrouter操作でUIが固まるのか
App Routerでは、router.push()やrouter.replace()でURLパラメータを変更すると、サーバーコンポーネントの再フェッチが走ります。
ユーザーがフィルターをクリック
→ router.replace('?productId=1')
→ サーバーコンポーネントがAPIを叩いてデータ再取得
→ データが返ってくるまでUIが固まる 😱
この間、クライアント側のstate更新もブロックされるため、ローディング表示すら出せません。
startTransitionで包む
router.replace()をstartTransitionで包むと、この問題が解決します。
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
// URLパラメータの更新が完了するまでの間、isPendingがtrueになる
const [isPending, startTransition] = useTransition();
const handleItemFilterToggle = (productId: number) => {
const params = new URLSearchParams(searchParams);
// 緊急: ローディング状態を即座に設定
setLoadingForProductId(newProductId);
// 非緊急: router.replace() をトランジションとしてマーク
startTransition(() => {
params.set('productId', productId.toString());
router.replace(`?${params.toString()}`, { scroll: false });
});
};
こうすることで以下の流れになります。
ユーザーがフィルターをクリック
→ ローディング表示が即座に出る ✅
→ router.replace()が非緊急として実行される
→ サーバーコンポーネントの再フェッチが裏側で進む
→ データが返ってきたらUIが更新される
isPendingでローディングを出す
isPendingを使えば、サーバーコンポーネントの再フェッチ中にローディング表示を出せます。
{/* フィルターボタンをpending中は非活性にする */}
<button
type='button'
onClick={() => setIsItemFilterOpen(true)}
disabled={isLoading || isPending}
>
つくりたいアイテムで絞り込む
</button>
{/* pending中はローディング表示 */}
{isPending && <CommonLoading />}
ポイントは、isPendingを活用してUIのフィードバックを充実させることです。
- ボタンを
disabledにして二重クリックを防ぐ - ローディングスピナーを表示して「処理中」と伝える
- コンテンツ領域を非表示にしてチラつきを防ぐ
useTransitionを使うべきタイミング
すべてのstate更新にuseTransitionを使う必要はありません。以下のケースで効果を発揮します。
使うべき場面
| ケース | 具体例 |
|---|---|
| 重いstate更新がUIをブロックする | 大量データのフィルタリング、ソート |
| router操作でUIが固まる |
router.push(), router.replace()での画面遷移・パラメータ更新 |
| ユーザー入力の応答性を保ちたい | 検索インクリメンタルサーチ |
使わなくてよい場面
| ケース | 理由 |
|---|---|
| 軽いstate更新 | そもそもブロックが発生しない |
| 入力フォームのバリデーション | 入力値そのものは緊急更新すべき |
| APIのfetch完了待ち |
useTransitionは非同期処理を待つものではない(※後述) |
注意点:startTransitionの中にはstate更新を入れる
startTransitionはstate更新をトランジションとしてマークするものです。awaitやPromiseを直接入れても意味がありません。
// ❌ こうではなく
startTransition(async () => {
const data = await fetchData();
setItems(data);
});
// ✅ こう使う(state更新だけを包む)
const data = await fetchData();
startTransition(() => {
setItems(data);
});
まとめ
-
useTransitionは、state更新を「緊急」と「非緊急」に分けるフック -
startTransitionで非緊急の更新を包み、isPendingでローディング表示を出す - Next.js App Routerでは
router.replace()をstartTransitionで包むことで、URLパラメータ更新中もUIの応答性を保てる