useTransition を使うと、stateの更新を緊急でないものとして扱えます。Reactはその更新を裏でレンダリングし、クリックやキー入力のようにもっと緊急なことが起きたら、それを捨てるかやり直します。そのため、画面の遅い部分が追いつく間も、ページの応答性が保たれます。
postsをクリックし、リストが表示される前にcontactをクリックしてください。contactはすぐに表示されます。Reactが途中までのpostsのレンダリングを捨てたのです。次に startTransition(() => { setTab(next); }) を普通の setTab(next); に変えて同じことを試してください。postsをクリックした後、ページが約300ミリ秒固まり、contactのクリックはpostsが終わるまで待たされます。
構文
const [isPending, startTransition] = useTransition();
startTransition(() => {
setTab(next);
});
startTransitionは関数を受け取ってすぐに実行します。その中のstateの更新は、すべてトランジションとして扱われます。isPendingは、トランジションを始めた瞬間から、Reactがそのレンダリングを終えて結果をコミットするまでtrueです。
useTransition は引数を取りません。渡した関数は同期的に実行されます。トランジションが関わるのは、結果として起きる更新をReactがどうレンダリングするかであって、関数がいつ実行されるかではありません。
「緊急でない」の意味
Reactは更新を2種類に分けます。入力、クリック、押下のような緊急の更新は、すぐに反映されないとページが壊れているように感じられます。新しい画面への切り替えやフィルター結果の表示のようなトランジションの更新は、少し時間がかかっても構いません。
Reactはトランジションを小さく区切ってレンダリングし、コンポーネントの合間に緊急のものが来ていないかを確認します。来ていれば、トランジションを一時停止するか放棄し、緊急の更新を処理してから、最新のstateでトランジションをやり直します。トランジションのレンダリング中は前の画面が表示されたままで、Reactは組み立て途中の結果を表示しません。例でpostsの準備ができるまで、ボタンの横にLoadingの表示を出しつつ、aboutのテキストが表示され続けるのはそのためです。
中断はコンポーネントの合間で起きます。300ミリ秒止まる1つのコンポーネントを半分に切ることはできません。上の例で遅い処理を300個の小さな SlowPost コンポーネントに分けているのはそのためです。
保留中の表示を出す
isPending は、現在の中身を置き換えない読み込み中の表示に使います。ボタンの横のスピナー、薄くしたテキスト、「Saving...」のラベルなどです。最初の例では、タブの横に Loading... を表示しています。中身のスタイルに使うこともできます。
<div style={{ opacity: isPending ? 0.6 : 1 }}>{content}</div>
入力中に大きなリストをフィルターする
入力欄そのものの値をトランジションに入れることはできません。制御された <input> は同じ緊急の更新で新しい値を受け取らないと、文字が抜け落ちます。代わりに2つのstateを持ちます。入力欄のテキスト(緊急)と、リストが使う検索語(トランジション)です。
123 とすばやく入力してください。入力欄はまったく遅れません。Results はReactがレンダリングを始めた時点でログを出すので、コンソールにはキーを押すたびに rendering results が1行ずつ記録されますが、1 と 12 のリストが画面に届くことはありません。新しいキー入力が進行中のレンダリングを中断し、Reactが最新の検索語でやり直したからです。Results をあえて memo で包んでいます。これがないと、キー入力ごとの緊急のレンダリングでも、古い検索語で遅いリストがレンダリングされ、効果が失われてしまいます。
フックなしのstartTransition
react は startTransition を普通の関数としてもエクスポートしています。フックと同じように更新をトランジションとして扱いますが、isPending は得られません。データライブラリや、コンポーネントの外にあるルーターのように、フックを呼べない場所で使います。
import { startTransition } from 'react';
function navigate(url) {
startTransition(() => {
setCurrentUrl(url);
});
}
React 19の非同期トランジション
React 19からは、渡す関数を async にできます。そうすると isPending は待ち時間も含めて関数の実行中ずっと true のままなので、リクエスト中に保留中の状態を表示する簡単な方法になります。下の偽の saveName は本物のリクエストの代わりです。
setSaved を囲む2つ目の startTransition に注目してください。Reactは await をまたぐとトランジションを追跡できなくなるので、その後のstateの更新をトランジションの一部にするには、もう一度包む必要があります。このように渡された関数を、Reactはアクションと呼びます。useActionStateやフォームアクションは同じ仕組みの上に作られています。
トランジションはデバウンスではない
デバウンスは、最後のキー入力から決まった時間待ってから何かをします。トランジションは待ちません。すぐにレンダリングを始め、新しい更新が来れば古い作業を捨てます。速い端末では遅延がまったくなく結果が表示され、遅い端末ではReactがキー入力の合間にできるだけのことをします。トランジションにできないのは、ネットワークリクエストの数を減らすことです。キー入力のたびにfetchを始めるなら、fetchをデバウンスしてください。
トランジションとSuspense
トランジションは Suspense 境界の振る舞いも変えます。普通、境界の中のコンポーネントがサスペンドすると(lazyコンポーネントの読み込みや、use で読むデータ)、Reactは中身をフォールバックに置き換えます。サスペンドを起こした更新がトランジションなら、Reactは代わりにすでに画面にある中身を保ち、新しい中身の準備ができるまで isPending は true のままです。ルーターがナビゲーションのたびにページ全体のスピナーをちらつかせずに済むのはこのためです。ナビゲーションをトランジションで包むので、次のページを描画できるまで古いページが表示されたままになります。一度も表示されていない中身はやはりフォールバックを使うので、新しく現れる境界はいつもどおりに振る舞います。
うまくいかないこと
- タイムアウトの中の更新。
startTransition(() => setTimeout(() => setTab(next), 100))は更新をトランジションとして扱いません。startTransitionが戻った後に実行されるからです。代わりにタイムアウトの中にstartTransitionを入れてください。 - 1つの遅いコンポーネント。Reactが中断できるのはコンポーネントの合間だけなので、長い時間止まる1つのコンポーネントはやはり止まります。
- 入力欄の制御。上で述べたとおり、入力欄そのもののstateは緊急のままです。
stateのセッターを自分で持っておらず、propのように値を受け取るだけの場合は、useDeferredValueが同じ振る舞いをしてくれます。
よくある質問
useTransitionは何をするものですか?
中のstateの更新を緊急でないものとして扱う startTransition と、その更新のレンダリング中に true になる isPending を返します。Reactは緊急でないレンダリングを中断して、クリックや入力を先に処理できます。
useTransitionとstartTransitionの違いは何ですか?
どちらも更新をトランジションとして扱います。useTransition フックは、読み込み中の表示のための isPending も返します。react からインポートする単独の startTransition はコンポーネントの外でも動きますが、保留中フラグはありません。
テキスト入力にuseTransitionを使えますか?
入力欄そのものの値には使えません。制御された入力は同期的に更新しなければ、入力が壊れます。入力欄のstateは緊急のままにし、それに依存する重い更新をトランジションに入れるか、useDeferredValue を使ってください。
useTransitionはデバウンスと同じですか?
いいえ。デバウンスは何かをする前に決まった時間待ちます。トランジションはすぐにレンダリングを始め、新しい更新が来れば中断されるので、速い端末では遅延なしに結果が表示されます。
startTransitionにasync関数を渡せますか?
はい、React 19から渡せます。isPending はasync関数が終わるまで true のままです。await の後のstateの更新をトランジションとして扱うには、もう一度 startTransition で包む必要があります。