useTransitionで、前の画面を残したまま読み込みを待つ方法

​​React​Suspense,TypeScript,use,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 を返す関数を用意しました。

ts
// 検証用のデータ取得。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 で囲みました。

tsx
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 をそのまま呼ぶ書き方です。

tsx
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>
    </>
  );
}

動かす手順は、このあとの書き方とも同じです。最初の表示のあと「投稿」ボタンを押し、投稿のデータ、コメントのデータの順に届けました。各段階で、画面に出ている文字を並べています。

text
$ 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 のリファレンス)。

tsx
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 ではない要素の中にある文字に付けた印です。

text
$ 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回目のクリックで、①は「読み込み中…」だけになり、②は薄くなった「プロフィール」と「切り替え中…」になります。

2つの書き方を縦に並べ、投稿のタブに切り替えてから投稿とコメントのデータを届けたときの表示の変化
「投稿」タブを押し、投稿、コメントの順にデータを届ける動き(上:setTab をそのまま呼ぶ、下:startTransition で包む)
ポイント

表示中の画面を「読み込み中…」に置き換えたくない更新は、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 を付けない場合と付けた場合を比べました。

tsx
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 さんのデータを届けました。

text
$ 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 の撮影では、この間隔より長く待ってから画面を撮りました。

​Posted by 86