React 19のuseTransitionで実現する、ローディング体験
はじめに
ログイン → 認証後TOPページに遷移を実装中
「ログインボタンを押して処理完了したあとに、毎回画面が数秒固まる...?」
ログイン処理自体は正常に完了しているのに、次のページへのなかなか遷移しない。
ユーザーからすると、ローディングが終わったのに何も起きない数秒間は、まるでアプリがフリーズしたように感じられていました。
この問題を解決する過程で出会ったのが、React 18で導入されたuseTransition
フックです。最初は「また新しいフックか...」と思いましたが、使ってみると実践的で、ユーザー体験を改善できることがわかりました(キャッチアップしんどい)。
useTransitionとは?
useTransition
は、UI更新の優先度を制御するReactフックです。重い処理や時間のかかる更新を「低優先度」として扱い、ユーザーの操作をブロックしないようにします。
const [isPending, startTransition] = useTransition();
-
isPending
: トランジション中かどうかを示すboolean -
startTransition
: 低優先度の更新を開始する関数
基本的な使い方
1. シンプルな例:タブ切り替え
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab: string) {
// 重い処理を低優先度として実行
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton
isActive={tab === 'about'}
onClick={() => selectTab('about')}
>
About
</TabButton>
<TabButton
isActive={tab === 'posts'}
onClick={() => selectTab('posts')}
>
Posts (slow)
</TabButton>
<TabButton
isActive={tab === 'contact'}
onClick={() => selectTab('contact')}
>
Contact
</TabButton>
<hr />
{isPending && <div>Loading...</div>}
<TabContent tab={tab} />
</>
);
}
2. 検索フィールドの最適化
function SearchResults() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleSearch(value: string) {
// 入力フィールドは即座に更新
setQuery(value);
// 重い検索処理は低優先度で実行
startTransition(() => {
const filtered = performExpensiveSearch(value);
setResults(filtered);
});
}
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="検索..."
/>
{isPending ? (
<div>検索中...</div>
) : (
<ResultsList results={results} />
)}
</div>
);
}
実践例:ログインフォームでの活用
ここからは、実際に私が直面した問題と、その解決方法を紹介します。
問題の発見:「フリーズしてる?」という不安
私たちのアプリケーションでは、Next.js 15のServer Actionsを使ってログイン処理を実装していました。一見問題なく動いているように見えたのですが...
// 問題のあるコード
export function LoginForm() {
const [lastResult, action, isPending] = useActionState(loginAction, undefined);
useEffect(() => {
if (lastResult?.success) {
// Server Action完了後、ページ遷移開始
// ログインレスポンスを利用したクライアント側で行わなければならない処理があったので
// redirect風な実装を行なっている。
router.replace('/dashboard');
}
}, [lastResult]);
return (
<Button loading={isPending}>
ログイン
</Button>
);
}
何が問題だったか、わかりますか?
- ユーザーがログインボタンをクリック
- ローディングアニメーション開始(
isPending = true
) - サーバーでログイン処理完了
-
ローディング終了(
isPending = false
) ← ここ! - 数秒後、やっとページ遷移
つまり、ステップ4から5の間、ユーザーには何も起きていないように見えるんです。「あれ?ボタン押したよね?」「フリーズした?」という不安な数秒間が生まれていました。
原因を探る旅
最初は「ページ遷移が遅いなら、次のページのデータ取得を最適化すればいい」と考えました。でも、それだと根本的な解決にならないんですよね。なぜなら:
- Server Componentでデータを取得する以上、どうしても時間はかかる
- データ取得を省略すると、今度は空のページが表示されてしまう
- かといって、ログイン処理中にデータを先読みするのも違う...
そこで思い出したのが、以前React 18のリリースノートで見かけたuseTransition
でした。
解決策:「処理中」であることを伝え続ける
useTransition
を使って、こんな風に書き換えました:
export function LoginForm() {
const [lastResult, action, isPending] = useActionState(loginAction, undefined);
const [isTransitioning, startTransition] = useTransition();
const router = useRouter();
useEffect(() => {
if (lastResult?.success) {
// ページ遷移をトランジションとして実行
startTransition(() => {
router.replace('/dashboard');
});
}
}, [lastResult, router, startTransition]);
return (
<Button
loading={isPending || isTransitioning}
disabled={isPending || isTransitioning}
>
{isTransitioning ? 'ページ遷移中...' : 'ログイン'}
</Button>
);
}
これで何が変わったか:
- ログイン処理中:「ログイン」ボタンがローディング状態
- ログイン完了後:「ページ遷移中...」に変わり、引き続きローディング状態
- ページ表示完了:やっとローディングが終了
ユーザーからすると、ボタンを押してからページが表示されるまで、ずっと「処理中」であることがわかるようになりました。体感的なストレスが減少し、「フリーズした?」という不安もなくなりました。
useTransitionが効果的な場面
1. ページ遷移
// Next.jsのルーター遷移をスムーズに
startTransition(() => {
router.push('/heavy-page');
});
2. 大量データのフィルタリング
// 1万件のデータをフィルタリング
startTransition(() => {
const filtered = items.filter(item =>
item.name.includes(searchTerm)
);
setFilteredItems(filtered);
});
3. リアルタイム検索
// APIコールを含む検索
startTransition(async () => {
const results = await searchAPI(query);
setSearchResults(results);
});
4. タブやモーダルの切り替え
// 重いコンポーネントの表示切り替え
startTransition(() => {
setActiveTab('analytics'); // グラフ描画などの重い処理
});
実務での注意点
1. 適切な粒度で使用する
// ❌ 細かすぎる
items.forEach(item => {
startTransition(() => {
updateItem(item);
});
});
// ✅ 適切な粒度
startTransition(() => {
items.forEach(item => updateItem(item));
});
2. ユーザーフィードバックを忘れない
// isPendingを活用してローディング状態を表示
{isPending && <Spinner />}
{isPending && <div>処理中...</div>}
3. 緊急性の高い更新には使わない
// ❌ 入力フィールドの更新には使わない
startTransition(() => {
setInputValue(e.target.value); // 遅延して入力がもたつく
});
// ✅ 入力は即座に、結果表示は遅延
setInputValue(e.target.value); // 即座に更新
startTransition(() => {
setSearchResults(search(e.target.value)); // 結果は遅延OK
});
useTransitionとSuspenseの組み合わせ
function Dashboard() {
const [isPending, startTransition] = useTransition();
const [resource, setResource] = useState(initialResource);
function refresh() {
startTransition(() => {
setResource(fetchData()); // Suspenseと連携
});
}
return (
<div>
<button onClick={refresh} disabled={isPending}>
更新
</button>
{isPending && <div>更新中...</div>}
<Suspense fallback={<Skeleton />}>
<DataComponent resource={resource} />
</Suspense>
</div>
);
}
パフォーマンスへの影響
メリット
- メインスレッドのブロッキング回避: 重い処理中もUIが応答
- 優先度制御: ユーザー操作を優先
- 中断可能: より緊急な更新があれば中断
デメリット
- 遅延の可能性: 低優先度なので処理が後回しに
- 複雑性: 状態管理が少し複雑に
使ってみて分かったこと
実際にuseTransition
を導入してみて、いくつか気づいたことがあります。
良かった点
-
ユーザーの不安を解消できた
- 「処理中」であることが明確に伝わるようになった
- フィードバックで「フリーズ」という言葉が出なくなった
-
実装がシンプル
- 既存のコードにほとんど手を加えずに導入できた
- 特別な状態管理ライブラリも不要
-
汎用性が高い
- ページ遷移以外にも、検索やフィルタリングなど様々な場面で活用できる
注意すべき点
-
すべてに使えばいいわけではない
- 入力フィールドなど、即座に反応すべき部分には使わない
- あくまで「遅延してもいい処理」に限定する
-
ユーザーフィードバックは必須
-
isPending
を使ってローディング状態を表示しないと意味がない - 「何を処理中なのか」を明示するとより親切
-
まとめ
useTransition
は、一見すると「また新しいフックか...」と思うかもしれません。私も最初はそうでした。でも、実際に使ってみると、ユーザー体験を改善する実用的なツールだということがわかりました。
特に今回のようなケースでは:
- 技術的には正しく動いているのに、UX的に問題がある
- バックエンドの処理速度は改善できない(または改善に限界がある)
- でも、ユーザーには快適に使ってもらいたい
そんな時に、useTransition
は良い解決策を提供してくれます。
もし「ローディングが終わったのに画面が動かない」「処理中なのかフリーズなのかわからない」といった問題に直面したら、ぜひuseTransition
を試してみてください。きっと、ユーザーも開発者も幸せになれるはずです。
参考リンク
実装例のコードは実際のプロダクションコードを基にしています。useTransition
を活用して、より良いユーザー体験を提供していきましょう!
Discussion