React 19のuseでPromiseとコンテキストを読む方法

​​React​ESLint,Suspense,TypeScript,useContext

React 19 の use を使うと、Promise の結果とコンテキストの値を、描画中に1つの API で読めます。Promise の待ち時間は <Suspense> が、失敗は Error Boundary が受け持ちます。そのため、コンポーネントの中に「読み込み中」や「エラー」の state を持たずに済みます。エフェクトでデータを取得して state に入れるやり方では、これらを自分で管理する必要がありました。2つの書き方の違いは、公式リファレンスにある、useEffect で取得する書き方との比較で確かめられます。

ただし、use に渡す Promise は、描画のたびに作り直してはいけません。筆者は検証環境で、キャッシュした Promise を渡す書き方と、描画のたびに Promise を作る書き方を TypeScript(.tsx)で書き比べました。そして、画面の変わり方と、作られた Promise の数を調べました。あわせて、try-catch の中で呼んだときと、if のあとでコンテキストを読んだときの動きも確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 6.0.3、esbuild 0.28.2、jsdom 29.1.1、eslint 10.13.0、eslint-plugin-react-hooks 7.1.1、typescript-eslint 8.72.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で描画しました。GIF は Playwright 1.56.1 の Chromium で撮りました。データの取得には、決まった時間がたつと解決する偽の関数を使っています。

use で読めるのは Promise とコンテキスト

use は、React 19 で加わった、描画中にリソースを読むための API です(React 19 の発表記事)。公式リファレンスの説明によると、Promise を渡すと、そのコンポーネントは Promise が解決するまで中断(suspend)します。解決すると、use はその値を返します。

名前はフックと同じく use で始まりますが、フックではありません。フックと違い、ループや if の中でも呼べます。ただし、呼べるのはコンポーネントかフックの中だけです(リファレンスの注意事項)。

Promise を読む:待ち時間は Suspense、失敗は Error Boundary が受け持つ

検証では、アルバムの一覧を返す偽のデータ取得関数を用意しました。getAlbums() は、呼ぶたびに新しい Promise を作り、100ms 後に解決します。fetchAlbums(key) は、同じキーなら同じ Promise を返す簡単なキャッシュです。これは、リファレンスにある、Client Component で Promise をキャッシュする例と同じ作りです。

ts
// 同じキーなら、同じ Promise を返す
const cache = new Map<string, Promise<Album[]>>();
export function fetchAlbums(key: string): Promise<Album[]> {
  if (!cache.has(key)) cache.set(key, getAlbums());
  return cache.get(key)!;
}

一覧を出すコンポーネントは、受け取った Promise を use に渡すだけです。

tsx
export function Albums({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  const albums = use(albumsPromise);
  return (
    <ul>
      {albums.map((album) => (
        <li key={album.id}>{album.title} ({album.year})</li>
      ))}
    </ul>
  );
}

親では、Albums を <Suspense> で包み、キャッシュした Promise を渡します。ボタンは、親を再描画させるためのものです。

tsx
export function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>再描画 {count}</button>
      <Suspense fallback={<Loading />}>
        <Albums albumsPromise={fetchAlbums('/albums')} />
      </Suspense>
    </>
  );
}

描画してから 300ms 待ち、そのあと「再描画」ボタンを3回押しました。押したあとも、毎回 300ms 待っています。それぞれの時点で画面に出ていた文字は、次のとおりです。

text
$ node run.mjs ./out/CachedApp.mjs
  描画した直後: 再描画 0 読み込み中…
  300ms 後    : 再描画 0 Neko Walk (2024) Paw Print (2025)
  クリック1回目の直後: 再描画 1 Neko Walk (2024) Paw Print (2025) / 300ms 後: 再描画 1 Neko Walk (2024) Paw Print (2025)
  クリック2回目の直後: 再描画 2 Neko Walk (2024) Paw Print (2025) / 300ms 後: 再描画 2 Neko Walk (2024) Paw Print (2025)
  クリック3回目の直後: 再描画 3 Neko Walk (2024) Paw Print (2025) / 300ms 後: 再描画 3 Neko Walk (2024) Paw Print (2025)
  作った Promise=1 fallback を出した回数=1

fallback の「読み込み中…」が出たのは最初だけで、Promise が解決すると一覧に置き換わりました。再描画しても同じ Promise が渡るので、3回とも fallback は出ていません。作った Promise も1個のままです。

Promise が失敗したときは、リファレンスにある、Error Boundary でエラーを表示する例のとおり、エラーはいちばん近い Error Boundary に届きます。100ms 後に失敗する Promise を用意し、クラスで書いた Error Boundary の内側で読ませました。表示は次のように変わりました。

text
ErrorApp: 直後=読み込み中… / 300ms 後=エラー: 読み込みに失敗しました

描画のたびに Promise を作ると、再描画のたびに読み込み中に戻る

次に、親の fetchAlbums('/albums') を getAlbums() に変えました。親が描画されるたびに、新しい Promise ができる書き方です。それ以外は同じ手順で動かしました。

text
$ node run.mjs ./out/UncachedApp.mjs
  描画した直後: 再描画 0 読み込み中…
  300ms 後    : 再描画 0 Neko Walk (2024) Paw Print (2025)
  クリック1回目の直後: 再描画 1 読み込み中… / 300ms 後: 再描画 1 Neko Walk (2024) Paw Print (2025)
  クリック2回目の直後: 再描画 2 読み込み中… / 300ms 後: 再描画 2 Neko Walk (2024) Paw Print (2025)
  クリック3回目の直後: 再描画 3 読み込み中… / 300ms 後: 再描画 3 Neko Walk (2024) Paw Print (2025)
  作った Promise=4 fallback を出した回数=4

ボタンを押すたびに、一覧が消えて「読み込み中…」に戻りました。作った Promise は4個、fallback も4回です。データの中身は変わっていないのに、再描画のたびに読み込みからやり直しています。

次の GIF アニメーションは、この2つの書き方を Chromium で上下に並べ、「再描画する」ボタンを3回押した様子です。上の「① キャッシュした Promise を渡す」が fetchAlbums('/albums') を渡す版、下の「② 描画のたびに新しい Promise を渡す」が getAlbums() を渡す版です。待ち時間は、目で追えるように 600ms にしました。

上に「① キャッシュした Promise を渡す」、下に「② 描画のたびに新しい Promise を渡す」を並べ、「再描画する」ボタンを3回押したときの表示と、作った Promise の数の変化
GIFアニメーション:「再描画する」ボタンを3回押したときの動き。①は一覧が出たままで、作った Promise は1個。②は押すたびに読み込み中に戻り、Promise が増える

②は、リファレンスの、Client Component で Promise をキャッシュする説明で、避けるべきだとされている書き方です。描画中に新しい Promise を作ると、再描画のたびに Suspense の fallback が出ると説明されています。

use を呼ぶコンポーネントの中で Promise を作ると、表示されないことがある

Promise を、use を呼ぶコンポーネントの中で作る書き方も試しました。

tsx
// use を呼ぶコンポーネントの中で Promise を作る
function Albums({ ms }: { ms: number }) {
  const albums = use(getAlbums(ms));
  return <p>{albums.length}件</p>;
}

act を使わずに描画し、2秒間の画面の様子を見ました。待ち時間が 100ms のときは、2秒たっても「読み込み中…」のままでした。この実行では、そのあいだに Promise が23個作られています。描画が中断するたびに React が最初から描画し直し、そのたびに新しい Promise が作られて、また中断する、ということをくり返したためです。リファレンスの、Promise が作り直される理由の解説にも、マウント前に中断した描画では state が残らず、やり直すたびに Promise が作り直されると書かれています。

text
$ node run-inrender.mjs 100
  500ms 後: 読み込み中… 作った Promise=8
  1000ms 後: 読み込み中… 作った Promise=13
  2000ms 後: 読み込み中… 作った Promise=23

待ち時間を 0ms にすると、表示まで進みました。ただし、この実行では、表示までに Promise が38個作られています。また、0ms のときだけ、次のメッセージが console.error に出ました。100ms のときは出ていません。このメッセージは発表記事にも載っていて、原因と直し方はリファレンスのトラブルシューティングで説明されています。

text
A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.

なお、eslint-plugin-react-hooks 7.1.1 は、②の書き方にも、use を呼ぶコンポーネントの中で作る書き方にも、何も出しませんでした。

注意

描画のたびに Promise を作って use に渡すと、lint は通りますが、再描画のたびに画面が「読み込み中」に戻ったり、いつまでも表示されなかったりします。検証では、コンソールにメッセージが出ない場合もありました。

Promise の用意のしかたについて、リファレンスの、use に渡す Promise はキャッシュが必要だという注意は、キャッシュ、Suspense に対応したフレームワーク、Server Component のいずれかから渡すよう勧めています。同じページの、Promise が作り直される理由の解説では、イベントハンドラーやルートのローダー、Server Component など、描画より前の段階で作るのが理想だとしています。フレームワークや Server Component と組み合わせた形は、検証環境では動かしていません。

use を try-catch で囲まない

use は、try-catch の中では呼べません(リファレンスの注意事項)。試しに、まだ解決していない Promise を読む use を、try-catch で囲みました。

tsx
function Albums({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  let text: string;
  try {
    text = `${use(albumsPromise).length}件`; // try-catch の中で呼んだ
  } catch (e) {
    text = `catch: ${(e as Error).message}`;
  }
  return <p>{text}</p>;
}

すると、React が内部で使う「中断の合図」を、catch が受け取ってしまいました。Promise が解決したあとも、画面は次の表示のままでした(メッセージが長いので、最初の2文だけ写します)。

text
catch: Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render.

console.error には、「use was called from inside a try/catch block.」で始まるメッセージも出ました。lint も、この行を React Hook "use" cannot be called in a try/catch block というエラーにします。失敗に備えるときは、try-catch ではなく、さきほどの例のように Error Boundary で包みます(リファレンスのトラブルシューティング)。

コンテキストは if のあとでも読める

コンテキストを渡すと、use は useContext と同じように値を返します。違いは呼べる場所です。useContext はコンポーネントのトップレベルで呼ぶ必要がありますが、use は if や for の中でも呼べます(リファレンスの、use でコンテキストを読む例)。発表記事の例にならって、早めに return したあとでコンテキストを読む形を書きました。

tsx
export function Heading({ children }: { children?: ReactNode }) {
  if (children == null) {
    return null;
  }
  const theme = use(ThemeContext);
  return <h1 className={theme}>{children}</h1>;
}

値を dark にした ThemeContext の内側で、同じ Heading を children なし、ありの順に描画しました。1回目は何も描画されず、2回目は <h1 class="dark">ねこ</h1> になりました。lint も何も出しません。

同じ位置で useContext を呼ぶ版も用意したところ、lint はエラーにしました。

text
./src/Theme.tsx
  19:17  error  React Hook "useContext" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?  react-hooks/rules-of-hooks

この useContext 版も、検証の手順では同じ表示になり、実行時のエラーや警告は出ませんでした。ただ、発表記事は、この例を useContext ではうまくいかない書き方として示しています。useContext 版はフックのルールに反するので、条件のあとでコンテキストを読みたいときは use を使います。

コンテキストに Promise を入れて、use を2回呼ぶ

コンテキストの値に Promise を入れておけば、props で受け渡さなくても、非同期のデータを配れます。読む側では、まずコンテキストから Promise を取り出し、次にその Promise の中身を読みます。そのため、use を2回呼びます(リファレンスの、コンテキストから Promise を読む例)。

tsx
function Profile() {
  const userPromise = use(UserContext); // まずコンテキストから Promise を読む
  if (!userPromise) return <p>ログインしていません</p>;
  const user = use(userPromise); // 次に Promise の中身を読む
  return <h1>{user.name}</h1>;
}

<Suspense> で包んで描画すると、直後は「読み込み中…」と出て、300ms 後には <h1>Mike</h1> になりました。この例でも、2つ目の use は if のあとで呼んでいます。

ポイント

use に渡す Promise は、描画の外で作るか、キャッシュから受け取ります。そうしておけば、待ち時間は <Suspense> に、失敗は Error Boundary に任せられます。コンテキストは、条件のあとでも use で読めます。

​Posted by 86