React

Async Reactとは?Transition・Suspense・useOptimisticで非同期UIを組む実装【React 19.3】

Async Reactとは?Transition・Suspense・useOptimisticで非同期UIを組む実装【React 19.3】

Async Reactは、React Conf 2025でReactチームのRicky Hanlon氏が行った講演「Async React Part I/Part II」の名前です。importできる単独のAPIではありません。Transition(Actions)・Suspense・楽観的更新を組み合わせ、通信が速ければ読み込み表示を出さず、遅ければ自動で出す画面を、画面側のコードを増やさずに作る書き方を指します。

この記事では、npmの最新安定版React 19.3.0(2026年9月9日公開)を前提に、各APIのコードと実行結果を示します。検証はreact/react-dom 19.3.0をjsdom上で動かして行いました。Suspense配下でawait後の更新を包み直さないと読み込み表示に戻る、Promiseをレンダー中に作ると読み込み中のまま止まる、といった失敗の出方も載せています。

まとめ:Async Reactの正体とReact 19.3.0での書き方の要点

  • Async Reactは講演名とワーキンググループ名で、実体は startTransition(async関数=Actions)、useOptimisticuse<Suspense> の組み合わせです。
  • async関数を startTransition に渡せるのはReact 19.0.0からです。await後の set 呼び出しはもう一度 startTransition で包まないとTransition扱いになりません。その更新で表示済みの <Suspense> 配下が中断すると、表示中の内容がfallbackに置き換わります。
  • useOptimistic の値はAction完了時に第1引数の値へ置き換わります。親の状態を確定値で更新しないと、表示が巻き戻ります。
  • use に渡すPromiseはキャッシュが必須です。レンダーのたびに作ると、読み込み中の表示から抜けません。
  • asyncなコンポーネントを書けるのは、現時点ではServer Componentsだけです。クライアントでは use でPromiseを読みます。

以下、定義、Actions、楽観的更新、データ読み込み、Server Components、Next.js、採用しない場面の順に説明します。

Async Reactの定義:React Conf 2025の講演名とワーキンググループの対象

単独のAPIではなく、Transition・Suspense・楽観的更新を束ねた呼び名

React公式ブログのReact Conf 2025振り返り記事(2025年10月16日)は、Ricky Hanlon氏の「Async React Part I」「Part II」を、過去10年の積み重ねで何ができるようになったかを示した講演として紹介しています。講演の最終版デモは GitHub の rickhanlonii/async-react にあり、READMEには実装側の役割分担が書かれています。ルーターはナビゲーションを既定でTransitionとして扱い、データ取得層は既定でSuspenseを使います。デザインコンポーネントは action propを公開し、楽観的更新と遅延表示の読み込み状態を内側で受け持ちます。

2025年10月8日には GitHub に reactwg/async-react(Async React Working Group)が作られました。Discussion #1が挙げる対象は、Transitions/Actions、Suspense/Suspending、Optimistic/Pending の3系統です。重点は、これらの機能をデータ取得・ルーティング・デザインコンポーネントの各ライブラリで使えるようにすることで、ドキュメントや講演による普及も目標に含まれます。議論は誰でも閲覧でき、参加は招待制です。

アプリ開発者の仕事は、ライブラリが用意した action や Suspense 対応のデータ取得に処理を渡すことです。isLoadingフラグを画面ごとに手で管理する書き方から、この分担へ移すのがAsync Reactの中身です。

名前の似たnpmパッケージ「react-async」は、Promiseベースのデータ読み込みライブラリで、この講演とは関係ありません。npmの最新版は2020年3月27日公開の10.0.1です。

構成APIと導入バージョンの対応表

API 役割 安定版で導入
useTransition/startTransition 更新を非緊急として扱う 18.0.0
useDeferredValue 値の反映を後回しにする 18.0.0
async関数のAction 通信を含めてTransitionにする 19.0.0
useOptimistic 完了前に結果を表示する 19.0.0
useActionState Actionの結果と保留状態を持つ 19.0.0
use PromiseやContextを読む 19.0.0
<Activity> UIと状態を隠したまま保持する 19.2.0
<ViewTransition> 非緊急の更新をアニメーションする 19.3.0

導入バージョンは facebook/react の CHANGELOG と React 19.2/19.3 のリリース記事によります。React 18のままでは、async関数のActionと useOptimisticuse が使えません。19.3.0で安定化した <ViewTransition> がアニメーションするのは、startTransition 内の更新、<Suspense> の表示切り替え、useDeferredValue による更新です。緊急の更新はアニメーションしないため、アニメーションも「どの更新をTransitionにするか」の設計に乗ります。React 19全体の変更点はReact 19の変更点と最新バージョンの解説にまとめています。

Actionsの実装:startTransitionにasync関数を渡すときのawait後の扱い

isPendingで保存中を出す基本形

React 19.0.0から startTransition はasync関数を受け取れます。この関数をReactは「Action」と呼び、awaitした通信が終わるまでTransitionを続けます。数量をサーバーに保存する例です(updateQuantity は保存して確定値を返す関数で、ここでは省略します)。

import { useState, useTransition } from 'react';

function Checkout() {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();

  function handleChange(next) {
    startTransition(async () => {
      const saved = await updateQuantity(next);
      startTransition(() => {
        setQuantity(saved);
      });
    });
  }

  return (
    <>
      <button onClick={() => handleChange(quantity + 1)}>+1</button>
      <span>{isPending ? '更新中…' : '数量: ' + quantity}</span>
    </>
  );
}

保存に40msかかる関数でボタンを押すと、表示は「更新中…」から「数量: 2」へ直接切り替わりました。isPending を自分の useState で持つ必要はありません。

await後の更新を包み直さない場合のfallback再表示(実行結果)

react.dev の useTransition リファレンスは、await より後の set 呼び出しを別の startTransition で包む必要があると明記しています。将来直す予定の既知の制限です。包まなかった場合の違いを、30msで解決するデータを use で読む <Suspense> 配下で比べました。表示中の結果がある状態で、5msのawaitの後に検索条件を変え、表示を1msごとに記録しています。

await後の書き方 データ解決前 データ解決直後(約50ms) fallbackの確定
setQ(‘b’) をそのまま呼ぶ 旧結果のまま(isPending=true) 読み込み中(isPending=false) あり
startTransition で包む 旧結果のまま(isPending=true) 新しい結果 なし

包まない書き方では、表示済みの結果が display: none で隠され、fallbackが確定しました。この時点で isPending は既に false で、ボタン側は処理が終わったように見えます。新しい結果が出たのはクリックから約350ms後です。React 19.0.0でSuspenseの表示間隔の制御(throttling)が500msから300msに短縮されており、react-dom 19.3.0にもこの値が入っています。ボタンの二重押し防止に isPending を使う画面では、包み忘れがそのまま不具合になります。

useOptimisticの実装:保存完了前の表示と巻き戻りの条件

useOptimistic は、Actionの実行中だけ一時的な値を見せるフックです。「いいね」ボタンで、サーバーの応答を待たずに数を増やす例です。

import { useState, useOptimistic, startTransition } from 'react';

function LikeButton({ likes, saveLike }) {
  const [optimisticLikes, addOptimistic] = useOptimistic(
    likes,
    (current, delta) => current + delta
  );

  function handleClick() {
    startTransition(async () => {
      addOptimistic(1);
      await saveLike();
    });
  }

  return <button onClick={handleClick}>♥ {optimisticLikes}</button>;
}

function Post() {
  const [likes, setLikes] = useState(10);

  async function saveLike() {
    const next = await postLike(likes);
    startTransition(() => setLikes(next));
  }

  return <LikeButton likes={likes} saveLike={saveLike} />;
}

このコードでは、クリック直後に「♥ 11」と表示され、保存完了後も11のままでした。saveLike から setLikes を外すと、表示は「♥ 11」から「♥ 10」へ戻ります。楽観的な値はActionが終わると捨てられ、第1引数の likes に置き換わるためです。不具合に見えますが仕様どおりの動きで、確定値で親の状態を更新するまでが実装に含まれます。

Actionの外で更新関数を呼ぶと、開発ビルドのコンソールに次の警告が出ます。

An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.

フォームの actionuseActionState と組み合わせる書き方は、React 19のform actionの使い方|引数はFormData・よくある誤りで扱っています。

useとSuspenseのデータ読み込み:Promiseをキャッシュする理由

キャッシュ済みPromiseを渡す基本形

use はPromiseを受け取り、解決するまでコンポーネントを中断(suspend)させます。中断中は最も近い <Suspense>fallback が出て、Promiseが拒否されると最も近いError Boundaryのfallbackが表示されます。react.dev はClient Componentで渡すPromiseについて、再レンダーのたびに同じインスタンスを使えるようキャッシュすることを求めています。

import { use, Suspense } from 'react';

const cache = new Map();
function fetchUser(id) {
  if (!cache.has(id)) {
    cache.set(id, fetch('/api/users/' + id).then((res) => res.json()));
  }
  return cache.get(id);
}

function UserName({ id }) {
  const user = use(fetchUser(id));
  return <p>{user.name}</p>;
}

function Profile({ id }) {
  return (
    <Suspense fallback={<p>読み込み中…</p>}>
      <UserName id={id} />
    </Suspense>
  );
}

応答に40msかかる fetch に差し替えて実行すると、「読み込み中…」の後にユーザー名へ切り替わりました。isLoadingの分岐はコンポーネントに書いていません。use はフックではないため条件分岐の中でも呼べ、詳しくはReactのuseフック(use API)の使い方で解説しています。

レンダー中にPromiseを作ったときの読み込み中の固着

次のように、キャッシュを通さずレンダー中に fetch を呼ぶ書き方は動きません。

function UserName({ id }) {
  const user = use(fetch('/api/users/' + id).then((res) => res.json()));
  return <p>{user.name}</p>;
}

解決を待って再レンダーするたびに新しいPromiseが作られ、また中断します。応答に40msかかる fetch で実行すると、1.5秒たっても表示は読み込み中のままで、その間に fetch が43回呼ばれていました。react-dom 19.3.0の開発ビルドには「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.」という警告文(console.error)がありますが、この条件ではコンソールに出ませんでした。エラーが出ないまま画面が止まるので、キャッシュ漏れは警告に頼らずコードで確認してください。

useEffect内の取得とSuspense対応ライブラリの境界

react.dev の <Suspense> リファレンスには、EffectやイベントハンドラーでのデータのフェッチをSuspenseは検知しないと書かれています。useEffect で取得して useState に入れる既存コードを <Suspense> で囲んでも、fallbackは出ません。

自前のMapキャッシュは、無効化や再取得の仕組みを持ちません。本番の画面では、無効化や再取得を自前で書かずに済むSuspense対応ライブラリに任せます。TanStack Query 5.102.8 は useSuspenseQuery を公開しており、キャッシュと再検証をライブラリ側に任せたまま <Suspense> で待てます。使い方はTanStack Queryとは?React Queryとの違い・v5の使い方と脆弱性対策を参照してください。

asyncコンポーネントはServer Components限定:クライアントで書いたときのエラー

サーバーでawait、クライアントでuseに渡す分担

Server Componentsでは、コンポーネント自体を async function にしてレンダー中に await できます。react.dev のServer Componentsリファレンスには、ページに必須のデータはサーバーでawaitし、優先度の低いデータはPromiseのままクライアントへ渡す例があります。次のコードはその例をもとに、Suspenseのimportやkeyなどを補ったものです。

// Server Component
import { Suspense } from 'react';
import db from './database';
import Comments from './Comments';

async function Page({ id }) {
  // 本文はサーバーで待つ
  const note = await db.notes.get(id);
  // コメントはawaitせず、Promiseのまま渡す
  const commentsPromise = db.comments.get(note.id);
  return (
    <div>
      {note.title}
      <Suspense fallback={<p>コメントを読み込み中…</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </div>
  );
}
// Comments.js(Client Component)
'use client';
import { use } from 'react';

export default function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

本文の表示はコメントの取得を待ちません。クライアント側の Comments に40msで解決するPromiseを渡すと、「コメントを読み込み中…」の後に一覧へ切り替わりました。サーバーとクライアントの境界の決め方はReact Server Components(RSC)とは?で詳しく扱っています。

async Client Componentのエラーメッセージと原因

同じ書き方をクライアントですると失敗します。async function のコンポーネントをクライアントで描画したところ、fallbackのまま次のエラーが出ました(%s にはコンポーネント名が入ります)。

%s is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.

メッセージのとおり、サーバー用に書いたファイルへ 'use client' を足したときによく起きます。直し方は、async関数をServer Componentに残してPromiseを渡すか、クライアント側で use に置き換えるかのどちらかです。useEffect のコールバック自体を async にするのは別の誤りです。Promiseがクリーンアップ関数の位置に返るため、React 19.3.0では「must not return anything besides a function, which is used for clean-up.」というエラーがコンソールに出ます。Effectの中でasync関数を定義して呼び出してください。

Next.js App Routerのloading.jsとSuspense境界の範囲

Next.js App Routerでは、loading.js を置くと同じセグメントの page.js と配下が自動で <Suspense> に包まれます。loading.js の内容がfallbackで、読み込み中の表示はプリフェッチされます。Next.jsのドキュメントによると、同じセグメントの layout.jstemplate.jserror.js は包まれません。レイアウトがキャッシュされていないデータや cookies()headers() などのランタイムデータを読むと、loading.js のfallbackはそのレイアウトには効きません。Cache Componentsを使っていない場合は、レイアウトの描画が終わるまでナビゲーションが止まります。

Next.jsのサポートポリシーでは現行の16.xがActive LTSで、npmの最新版は16.3.5です。ページ全体を1つの loading.js で待たせるより、遅いデータだけを <Suspense> で個別に囲む方が、先に出せる部分が増えます。16系で最小構成のアプリを動かす手順はNext.jsとは?16系App Routerで最小アプリを動かす手順と採用判断にあります。

Async Reactに書き換えない方がよい画面の条件

既存コードをすべて書き換える必要はありません。次の条件に当てはまる画面は、今の書き方を残す方が安全です。

  • 入力欄の値そのもの:react.dev はTransitionの更新をテキスト入力の制御に使えないとしています。入力に連動する重い一覧には、入力値ではなく一覧側に useDeferredValue を使います。
  • React 18から上げられないアプリ:async関数のAction、useOptimisticuse は19.0.0からです。18では startTransition に同期関数しか渡せず、手書きのisLoading管理は残ります。
  • TanStack QueryのuseQueryで安定している画面:取得・キャッシュ・エラー表示が既に動いているなら、use へ置き換えても得るものは読み込み表示の置き場所だけです。Suspenseに寄せたいときは、画面単位で useSuspenseQuery に切り替える方が差分が小さく済みます。
  • 行ごとに独立した保存中表示が要る一覧:react.dev は、進行中のTransitionが複数あると現在はまとめて処理されると記しています。useOptimistic の値に保留フラグを持たせても、この制限は回避できません。React 19.3.0で2行を別々に保存したところ、保存の早い行も、遅い行の保存が終わるまで保存中の表示が残りました。行ごとに完了を見せたい一覧は、今の書き方を残します。

新しく作る画面では、データ取得をSuspense対応ライブラリかServer Componentsに任せ、更新をActionにする形を最初から選ぶのが手戻りの少ない進め方です。

よくある質問

Async Reactの書き方を使うにはReactのどのバージョンが必要ですか?

async関数を startTransition に渡すActions、useOptimisticuseActionStateuse は、いずれもReact 19.0.0(2024年12月5日)で安定版に入りました。React 18.0.0にあるのは、同期関数だけを受け取る useTransitionstartTransitionuseDeferredValue までです。2026年9月14日時点のnpm最新安定版は19.3.0で、<ViewTransition> もここで安定化しています。新規に始めるなら19.3系を選んでください。

setStateは非同期ですか?awaitで更新後の値を待てますか?

待てません。useStateset 関数の戻り値は undefined で、React 19.3.0で実行しても同じでした。await setCount(1) と書いても更新の完了は待たず、同じ関数内の変数は古い値のままです。更新は次のレンダーで反映されます。通信の完了まで保存中を表示したいなら、処理をActionにして useTransitionisPending を使うのが、react.dev のリファレンスが示す方法です。

useフックとasync/awaitはどう使い分けますか?

レンダー中の await は、asyncなServer Componentでしか使えません。Client Componentでは use でPromiseを読みます。use は名前に反してフックではなく、if文の中でも呼べます。ただしClient Componentに渡すPromiseはキャッシュされている必要があります。サーバーでPromiseを作ってpropsとして渡し、クライアントの use で受け取る分担が、react.dev の示す組み合わせです。

useTransitionとuseDeferredValueはどう使い分けますか?

更新する状態の set 関数に自分でアクセスできるなら useTransition、propsやカスタムフックから来た値を遅らせたいなら useDeferredValue です。react.dev の useTransition リファレンスも、set 関数に触れない場合は useDeferredValue を試すよう案内しています。検索欄のように入力値そのものが絡む場合は、入力は即時に更新し、結果の一覧だけを useDeferredValue で遅らせます。

Async Reactの書き方でINPは改善しますか?

改善する可能性はありますが、書き換えだけで数値は約束されません。INPは2024年3月12日にFIDに代わってCore Web Vitalsの指標になり、web.devは200ms以下を良好としています。Transitionとして扱われた更新は、入力などの他の更新が来ると中断されます。そのため、重い再レンダーが操作への反応を塞ぐ画面では効果が出やすくなります。レンダー自体が軽い画面では差が出ないので、導入前後のINPを、Chrome DevToolsのPerformanceパネルと実ユーザーの計測値(CrUXなど)で比べて判断してください。

関連記事

資料請求

RELATED POSTS 関連記事