useTransitionで、前の画面を残したまま読み込みを待つ方法
タブを切り替えたとき、すでに表示していた画面が Suspense の「読み込み中…」に置き換わってしまうことがあります。state の更新を startTransition で包むと、React は前の画面を残したまま、次の画面のデータを待つようになります。
筆者は検証環境で、同じタブを2通りの書き方で動かし、データが届くまでの表示を比べました。あわせて、isPending で切り替え中を示す方法、入れ子の Suspense で待つ範囲を決める方法、key で別の内容に移るときに読み込み中に戻す方法も確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で描画しました。GIF の撮影には、Playwright 1.56.1 の Chromium を使いました。
検証用のデータを use で読む
Suspense のリファレンスにある、Suspense が働く場合の一覧によると、Suspense の境界が待つのは、lazy で読み込むコンポーネントや、use で読む Promise などです。同じリファレンスの注意事項には、エフェクトやイベントハンドラーの中で取得したデータは、Suspense では検出されないと書かれています。
そこで検証では、use で Promise を読みました。データがいつ届くかを自分で決められるように、finish を呼ぶまで解決しない Promise を返す関数を用意しました。
// 検証用のデータ取得。finish(key) を呼ぶまで Promise は解決しない。
const cache = new Map<string, { promise: Promise<string>; resolve: (v: string) => void }>();
export function fetchData(key: string): Promise<string> {
let entry = cache.get(key);
if (!entry) {
let resolve!: (v: string) => void;
const promise = new Promise<string>((r) => (resolve = r));
entry = { promise, resolve };
cache.set(key, entry); // 同じ key には同じ Promise を返す
}
return entry.promise;
}
export function finish(key: string, value: string) {
fetchData(key);
cache.get(key)!.resolve(value);
}
同じ key には同じ Promise を返すようにしています。Suspense に対応したフレームワークを使う場合は、フレームワークがこの Promise のキャッシュを受け持ちます(リファレンスにある、Suspense に対応したフレームワークについての説明)。
表示する画面は2つです。About はすぐに出る画面で、Posts は投稿の一覧を読み込む画面です。Posts の中のコメントは、別の Suspense で囲みました。
import { Suspense, use } from 'react';
import { fetchData } from './data';
export function About() {
return <p id="about">プロフィール</p>;
}
export function Posts() {
const posts = use(fetchData('posts'));
return (
<div>
<p id="posts">{posts}</p>
<Suspense fallback={<p id="comments-fallback">コメントを読み込み中…</p>}>
<Comments />
</Suspense>
</div>
);
}
function Comments() {
const comments = use(fetchData('comments'));
return <p id="comments">{comments}</p>;
}
setTab をそのまま呼ぶと、表示中の画面が消える
まずは、ボタンを押したら setTab をそのまま呼ぶ書き方です。
import { Suspense, useState } from 'react';
import { About, Posts } from './Pages';
type Tab = 'about' | 'posts';
export function Tabs() {
const [tab, setTab] = useState<Tab>('about');
return (
<>
<button id="to-posts" onClick={() => setTab('posts')}>投稿</button>
<Suspense fallback={<p id="fallback">読み込み中…</p>}>
{tab === 'about' ? <About /> : <Posts />}
</Suspense>
</>
);
}
動かす手順は、このあとの書き方とも同じです。最初の表示のあと「投稿」ボタンを押し、投稿のデータ、コメントのデータの順に届けました。各段階で、画面に出ている文字を並べています。
$ node run.mjs ./out/TabsPlain.mjs
./out/TabsPlain.mjs
1. 最初の表示: プロフィール
2. 「投稿」を押した直後: 読み込み中…
3. 投稿のデータが届いた: 投稿の一覧 / コメントを読み込み中…
4. コメントのデータが届いた: 投稿の一覧 / コメントの一覧
「投稿」を押した直後に、それまで出ていた「プロフィール」が消え、「読み込み中…」だけになりました。Posts が use で待ちに入り、いちばん近い Suspense が fallback に切り替わったからです。
Suspense のリファレンスにある、表示済みの内容が隠れるのを防ぐ方法では、表示中の内容がこのように fallback に置き換わると、使う人には唐突に見えると書かれています。
startTransition で包むと、前の画面を残したまま待つ
次に、setTab を startTransition で包みます。useTransition は、切り替え中かどうかを表す isPending と、startTransition 関数の2つを返します(useTransition のリファレンス)。
import { Suspense, useState, useTransition } from 'react';
import { About, Posts } from './Pages';
type Tab = 'about' | 'posts';
export function Tabs() {
const [tab, setTab] = useState<Tab>('about');
const [isPending, startTransition] = useTransition();
function selectTab(next: Tab) {
startTransition(() => {
setTab(next);
});
}
return (
<>
<button id="to-posts" onClick={() => selectTab('posts')}>投稿</button>
{isPending && <span id="pending">切り替え中…</span>}
<Suspense fallback={<p id="fallback">読み込み中…</p>}>
<div style={{ opacity: isPending ? 0.5 : 1 }}>
{tab === 'about' ? <About /> : <Posts />}
</div>
</Suspense>
</>
);
}
同じ手順で動かしました。「(薄く)」は、opacity が 1 ではない要素の中にある文字に付けた印です。
$ node run.mjs ./out/TabsTransition.mjs
./out/TabsTransition.mjs
1. 最初の表示: プロフィール
2. 「投稿」を押した直後: 切り替え中… / プロフィール(薄く)
3. 投稿のデータが届いた: 投稿の一覧 / コメントを読み込み中…
4. コメントのデータが届いた: 投稿の一覧 / コメントの一覧
「投稿」を押した直後も、「プロフィール」は残りました。「読み込み中…」は出ず、代わりに isPending が true になりました。画面には「切り替え中…」が出て、「プロフィール」は半透明になりました。投稿のデータが届くと、画面がまとめて投稿のタブに切り替わり、isPending も false に戻りました。
先ほどの Suspense のリファレンスによると、startTransition で包むことは、この更新が急ぎではなく、表示済みの内容を隠すより前の画面を出し続けるほうがよい、と React に伝えることになります。
次の GIF は、2つの書き方を Chromium の画面に縦に並べて動かした様子です。上の段が「① setTab をそのまま呼ぶ」、下の段が「② startTransition で包む」です。「「投稿」タブを押す」ボタンは、2つの段の「投稿」ボタン(GIF では隠しています)を順に押します。「投稿のデータを届ける」と「コメントのデータを届ける」ボタンは、finish を呼んで Promise を解決します。
押したあとに新しく出た表示や、薄さが変わった表示を、ピンクで示しています。1回目のクリックで、①は「読み込み中…」だけになり、②は薄くなった「プロフィール」と「切り替え中…」になります。

表示中の画面を「読み込み中…」に置き換えたくない更新は、startTransition で包みます。切り替え中であることは、isPending を使って前の画面の上に示します。
Suspense のリファレンスにある、トランジション中であることを示す例でも、isPending が true の間、ヘッダーの opacity を下げています。
トランジションが待つのは、表示済みの部分を隠さずに済むまで
出力の3番目の段階を見ると、トランジションで包んだ②でも、コメントの fallback(「コメントを読み込み中…」)が出ています。
useTransition のリファレンスにある、不要な読み込み中の表示を防ぐ例によると、トランジションが「待つ」のは、すでに表示している内容を隠さずに済むまでです。入れ子の Suspense があっても、その中身までは待ちません。Suspense のリファレンスにある、更新中に fallback に置き換わるのを防ぐ方法にも、新しく描画される Suspense の境界は、すぐに fallback を出すと書かれています。使う人が、届いた内容から順に見られるようにするためです。
この性質を使うと、待つ所とすぐ見せる所を分けられます。②の書き方では、投稿の一覧が届くまでは前のタブを出し続け、コメントは届くまで「コメントを読み込み中…」を出しました。どこまで待つかは、Suspense をどこに置くかで決まります。
別の内容に切り替えるときは、key で読み込み中に戻す
トランジションは表示済みの内容を隠さないので、困る場面もあります。たとえば、a さんのプロフィールを見ている状態で b さんに切り替えたとき、b さんのデータが届くまで、a さんの紹介が出たままになります。
Suspense のリファレンスにある、別の画面に移るときに境界をリセットする方法によると、key を変えると、React は別の内容として扱い、Suspense の境界をリセットします。key は、境界そのものに付けても、その上のコンポーネントに付けてもよいとされています。
検証では、次のコンポーネントで、key を付けない場合と付けた場合を比べました。
import { Suspense, use, useState, useTransition } from 'react';
import { fetchData } from './data';
function Bio({ userId }: { userId: string }) {
const bio = use(fetchData(`bio-${userId}`));
return <p id="bio">{bio}</p>;
}
function ProfilePage({ userId }: { userId: string }) {
return (
<Suspense fallback={<p id="fallback">読み込み中…</p>}>
<Bio userId={userId} />
</Suspense>
);
}
export function App({ withKey }: { withKey: boolean }) {
const [userId, setUserId] = useState('a');
const [isPending, startTransition] = useTransition();
return (
<>
<button id="next" onClick={() => startTransition(() => setUserId('b'))}>次の人</button>
{isPending && <span id="pending">切り替え中…</span>}
{withKey ? <ProfilePage key={userId} userId={userId} /> : <ProfilePage userId={userId} />}
</>
);
}
a さんのデータは先に届けておき、「次の人」を押してから、b さんのデータを届けました。
$ node profile.mjs
key なし
1. a さんを表示: a さんの紹介
2. 「次の人」を押した直後: 切り替え中… / a さんの紹介
3. b さんのデータが届いた: b さんの紹介
$ node profile.mjs key
key={userId} あり
1. a さんを表示: a さんの紹介
2. 「次の人」を押した直後: 読み込み中…
3. b さんのデータが届いた: b さんの紹介
key がない場合は、押した直後も a さんの紹介が出たままでした。key={userId} を付けた場合は、押した直後に「読み込み中…」に切り替わりました。jsdom で act の中の更新がすべて反映された時点では、「切り替え中…」は出ていませんでした。新しい境界はすぐに fallback を出すので、トランジションが待たずにすぐ確定したと考えられます。ブラウザで一瞬だけ出るかどうかは確かめていません。
同じタブの中身を更新するなら前の画面を残し、別の人のページに移るなら読み込み中に戻す、というように使い分けられます。リファレンスは、Suspense に対応したルーターなら、この key の扱いを自動で行うはずだとしています。
startTransition に入れる更新と、入力欄での注意
startTransition に渡した関数は、その場ですぐに呼ばれます。その関数の実行中に行った state の更新だけが、トランジションになります。useTransition のリファレンスの注意事項によると、setTimeout の中で行った更新はトランジションになりません。await のあとの更新も、現時点ではもう一度 startTransition で包まないとトランジションになりません。リファレンスは、これを将来直す予定の既知の制限としています。
同じ注意事項には、入力欄の値を持つ state の更新にはトランジションを使えない、とも書かれています。入力に応じてトランジションを使いたいときは、state を2つに分けるか、useDeferredValue を使います。この2つの方法は、リファレンスのトラブルシューティングで紹介されています。
Suspense のリファレンスの注意事項によると、React が待っていた中身を表示するのは、前回表示してから数えて、300ミリ秒に1回までです。その間に準備のできた境界は、まとめて表示されます。この間隔は、検証では測っていません。GIF の撮影では、この間隔より長く待ってから画面を撮りました。





