React で開発が進められている Concurrent Mode。
まだリリース前の開発中の機能だが、「実験的機能」として提供されており、Experimentalビルドをインストールすることで利用できる。
Experimentalはリリース間の安定性を何も保証しておらず、破壊的変更が行われる可能性がある。Concurrent Mode の動作も、大きく変わる可能性がある。
記事のタイトルに「2020年12月版」と入れたのは、そのため。
公式ドキュメントでは「並列モード」と翻訳されているが、まさに、並列的にレンダリングを行えるようになる。
ネットワークからデータを取得して要素をレンダリングする際に、ユーザーに見えないところで新しいレンダリングの準備をしつつ、データが取得できるまでは古いレンダリングを表示しておく、といったことが可能になる。
この記事では、どういった仕組みでそのようなことが可能になっているのか、ひとつずつ見ていく。
使用したライブラリのバージョンは以下の通り。
- react@0.0.0-experimental-4ead6b530
- react-dom@0.0.0-experimental-4ead6b530
- typescript@4.1.3
Concurrent Mode を有効にする
createRootを使って React 要素をマウントすると、その要素全体で Concurrent Mode が有効になる。
従来はReactDOM.render(element, container)だったものが、ReactDOM.createRoot(container).render(element)になる。
以下のコードでは、<App />全体で Concurrent Mode が有効になる。今回は TypeScript を使うため、トリプルスラッシュディレクティブで型定義も読み込んでいる。
/// <reference types="react-dom/experimental" /> import {unstable_createRoot} from 'react-dom'; import {App} from './components/App'; unstable_createRoot(document.querySelector<HTMLDivElement>('#app')!).render( <App /> );
createRootにunstable_という接頭語が付いているのは、この API がまだ実験的なものであり安定版ではないことを意味している。
バージョニングポリシー – React
Suspense はスローされた Promise をキャッチする
Concurrent Mode においては、Suspenseコンポーネントが重要な役割を果たす。
Suspenseコンポーネント自体は以前から存在したが、コンポーネントの Dynamic Import を行うために使われていた。
Concurrent Mode では、コンポーネント以外のリソース(例えば、ネットワークから取得するデータ)の取得を待機することができるようになった。
それを可能にしているのが、「スローされたPromiseをキャッチする」というSuspenseの機能である。
React ツリーのなかでPromiseがスローされると、ツリーを上に辿っていき、一番最初に到達したSuspenseのfallbackが表示される。
もし最後までSuspenseが見つからなかった場合、ツリー全体がアンマウントされる。
例えば以下のコードでは、ThrowPromiseコンポーネントがPromiseをスローしている。
そこからツリーを上に辿っていくと<Suspense fallback={<div>Fallback</div>}>が見つかるため、そのfallbackに設定されている<div>Fallback</div>が表示される。
/// <reference types="react/experimental" /> import {Suspense} from 'react'; function ThrowPromise() { throw Promise.resolve(1); return <div>foo</div>; } export function App() { return ( <Suspense fallback={<div>Fallback</div>}> <ThrowPromise /> </Suspense> ); }
「一番最初に到達したSuspenseのfallbackが表示される」ため、以下のコードでは2が表示される。
export function App() { return ( <Suspense fallback={<div>1</div>}> <Suspense fallback={<div>2</div>}> <ThrowPromise /> </Suspense> </Suspense> ); }
スローされたものをキャッチしてフォールバックを表示する、という点で Error Boundary に近い機能だと言える。
だが Error Boundary と違い、SuspenseはPromise以外のものがスローされた場合はキャッチしない。
そしてもうひとつ大きな違いが、Promiseの状態が変化すると、Suspenseでラップされた要素のレンダリングを再び試みる、という点である。
以下のコードを実行すると、1秒間Fallbackを表示したあと、fooが表示される。
/// <reference types="react/experimental" /> import {Suspense} from 'react'; let flag = false; function ThrowPromise() { if (!flag) { throw new Promise((resolve) => { setTimeout(() => { flag = true; resolve(null); }, 1000); }); } return <div>foo</div>; } export function App() { return ( <Suspense fallback={<div>Fallback</div>}> <ThrowPromise /> </Suspense> ); }
ThrowPromiseをレンダリングしようとすると、flagがfalseのため、Promiseがスローされる。そしてそれをキャッチしたSuspenseがフォールバックを表示する。
ここまでは、先程の例と同じ。
異なるのは、1秒後にPromiseの状態が変化すること。
そしてPromiseの状態が変化したことで、改めてThrowPromiseをレンダリングしようとする。その際にはflagの値がtrueになっているためPromiseはスローされず、div要素が返される。
レンダリングのトリガーになるのはPromiseの状態変化であり、fulfilledになるかrejectedになるかは、関係ない。
そのため、ThrowPromiseを以下のように書き換えても、同じように動作する。
function ThrowPromise() { if (!flag) { throw new Promise((_, reject) => { setTimeout(() => { flag = true; reject(new Error()); }, 1000); }); } return <div>foo</div>; }
Suspense とデータ取得を組み合わせる
ここまで説明したSuspenseの仕組みを使って、ネットワークからのデータ取得を実装してみる。
まず、API サーバを用意する。
const http = require('http'); function resJson(res, data, ms) { setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }); res.write(JSON.stringify(data)); res.end(); }, ms); } http .createServer((req, res) => { switch (true) { case /^\/1$/.test(req.url): resJson(res, {id: 1, name: 'Alice'}, 1000); break; case /^\/2$/.test(req.url): resJson(res, {id: 2, name: 'Bob'}, 1000); break; case /^\/3$/.test(req.url): resJson(res, {id: 3, name: 'Carol'}, 1000); break; case /^\/4$/.test(req.url): resJson(res, {id: 4, name: 'Dave'}, 1000); break; default: res.writeHead(404); res.end(); } }) .listen(3000);
挙動が分かりやすくなるように、1秒経過してからレスポンスを返すようにしてある。
そして、この API サーバを叩いてデータを取得する関数が、以下のfetchUser。
type Profile = { id: number; name: string; }; export function fetchUser(id: number) { let status = 'pending'; let result: Profile; let error: Error; const suspender = fetch(`http://localhost:3000/${id}`) .then((r) => { r.json().then((res) => { status = 'success'; result = res; }); }) .catch((e) => { status = 'error'; error = e; }); return { read() { if (status === 'pending') { throw suspender; } else if (status === 'error') { throw error; } return result; }, }; }
重要な点は、fetchUserの返り値はPromiseではないということ。{read() {...}}というオブジェクトを返す。
そしてreadメソッドがどのように動くのかは、呼び出したタイミングによって異なる。
fetchが返したPromiseの状態が変化する前に呼び出すとstatusがpendingなので、suspender(Promise)をスローする。
Promiseが解決されたあとに呼び出すとstatusはsuccessになっているので、result、つまり API からの返り値を返す。
fetchUserを利用する側のコードは以下の通り。
1秒間Loading...を表示したあと、1: Aliceを表示する。
/// <reference types="react/experimental" /> import {Suspense, useState} from 'react'; import {fetchUser} from '../api'; function Profile({resource}: {resource: ReturnType<typeof fetchUser>}) { const profile = resource.read(); return ( <div> {profile.id}: {profile.name} </div> ); } const initialResource = fetchUser(1); export function App() { const [resource] = useState(initialResource); return ( <Suspense fallback={<div>Loading...</div>}> <Profile resource={resource} /> </Suspense> ); }
以下のような処理の流れになる。
fetchUser(1)を実行し、データの取得を開始するfetchUser(1)の返り値をresourceにセットし、Profileコンポーネントに渡すProfileコンポーネントはresource.read()を実行してデータを取得しようとするが、まだデータ取得中なのでPromiseがスローされる- スローされた
PromiseをSuspenseがキャッチして、フォールバックを表示する - 約
1秒後、スローされたPromiseの状態がfulfilledに変化し、再度Profileコンポーネントをレンダリングしようとする Profileコンポーネントがresource.read()を実行すると、先程とは違って API からの返り値を取得でき、profile変数に代入されるprofile.idとprofile.nameを使ってレンダリングが行われ、Promiseもスローされていないので、フォールバックではなくProfileが表示される
データ取得中からデータ取得済みへと状態が変わり、それに伴って表示内容も変わっている。
だがstateへのセットは一度しか行われていない。従来は状態の変化に合わせて開発者がstateを更新し、状態に応じた表示の出し分けも開発者が書く必要があった。if (!profile) return <div>Loading ...</div>のように。
それが不要になったのは、状態の遷移に応じた処理を React が行うようになったため。データ読み込み中はfallbackを表示し、データ読み込みが完了したらfallbackの表示を止めてデータに基づいたレンダリングを行ってくれる。
コンポーネント側はシンプルに書けるようになった反面、データ取得側はSuspenseに対応した処理が必要になる。
「データを取得できるまではPromiseをスローし、取得後はデータを返す」という仕組みが重要なので、素朴にPromiseを返すだけでは、Suspenseと連携できない。
公式ドキュメントによると、Facebook では Relay を使ってSuspenseと連携させているとのこと。
並列的なレンダリングによるユーザ体験の向上
useTransitionは、 Concurrent Mode で追加された Hooks のひとつ。
これを使うことで、より柔軟に UI を設計できるようになる。
題材として、先程のコードを拡張し、ボタンを押下する度に次の ID のユーザが表示されるようにする。
まずは、useTransitionを使わずに書く。
/// <reference types="react/experimental" /> import {Suspense, useState} from 'react'; import {fetchUser} from '../api'; function Profile({ nextId, resource, handleClick, }: { nextId: number; resource: ReturnType<typeof fetchUser>; handleClick: () => void; }) { const profile = resource.read(); const onClick = handleClick; return ( <> <button type="button" onClick={onClick}> Next </button>{' '} <i>Next ID: {nextId}</i> <div> {profile.id}: {profile.name} </div> </> ); } const INITIAL_ID = 1; const initialResource = fetchUser(INITIAL_ID); export function App() { const [resource, setResource] = useState(initialResource); const [nextId, setNextId] = useState(INITIAL_ID + 1); const handleClick = () => { setNextId((id) => (id === 4 ? 1 : id + 1)); setResource(fetchUser(nextId)); }; return ( <Suspense fallback={<div>Loading...</div>}> <Profile nextId={nextId} resource={resource} handleClick={handleClick} /> </Suspense> ); }
基本的な仕組みは先程と変わらない。
ボタンを押下するとstateが更新されるため、再レンダリングが発生する。だがresource.read()でPromiseがスローされるため、フォールバックが表示される。Promiseの状態が変わったら、つまり API からレスポンスが返ってきたら、改めてレンダリングを試みる。そうすると今度はresource.read()で API からのレスポンスを取得できるので、無事にレンダリングされる。

次に、useTransitionを使った形に書き換える。
useTransitionの返り値の最初の要素である、startTransitionを使う。このstartTransitionには関数を渡すのだが、そのなかでresourceの更新を行うようにする。
/// <reference types="react/experimental" /> import {Suspense, useState, unstable_useTransition} from 'react'; import {fetchUser} from '../api'; function Profile({ nextId, isPending, resource, handleClick, }: { nextId: number; isPending: boolean; resource: ReturnType<typeof fetchUser>; handleClick: () => void; }) { const profile = resource.read(); const onClick = handleClick; return ( <> <button type="button" onClick={onClick} disabled={isPending}> {isPending ? 'Loading...' : 'Next'} </button>{' '} <i>Next ID: {nextId}</i> <div> {profile.id}: {profile.name} </div> </> ); } const INITIAL_ID = 1; const initialResource = fetchUser(INITIAL_ID); export function App() { const [resource, setResource] = useState(initialResource); const [nextId, setNextId] = useState(INITIAL_ID + 1); const [startTransition, isPending] = unstable_useTransition(); const handleClick = () => { setNextId((id) => (id === 4 ? 1 : id + 1)); // resource の更新を startTransition でラップした startTransition(() => { setResource(fetchUser(nextId)); }); }; return ( <Suspense fallback={<div>Loading...</div>}> <Profile nextId={nextId} isPending={isPending} resource={resource} handleClick={handleClick} /> </Suspense> ); }
そうすると、先程とは挙動が変わる。

具体的には、ボタンを押下してもフォールバックが表示されなくなった。
なぜこのような結果になるのか、ひとつずつ見ていく。
useTransition の仕組み
原則として、startTransitionを実行するとレンダリングが2回発生する(例外もあるので後述する)。startTransitionのなかで状態を更新しているかどうかは、無関係。
そのため以下のコードの場合、ボタンを押す度にダイアログが2回表示される。
function Child({count, handleClick}: {count: number; handleClick: () => void}) { useEffect(() => { alert(count); }); return ( <button type="button" onClick={handleClick}> child </button> ); } export function App() { const [startTransition] = unstable_useTransition(); const handleClick = () => { startTransition(() => {}); }; return <Child count={0} handleClick={handleClick} />; }

次にこのコードに状態管理を組み合わせる。
function Child({ foo, bar, handleClick, }: { foo: number; bar: number; handleClick: () => void; }) { useEffect(() => { alert(`foo: ${foo}, bar: ${bar}`); }); return ( <button type="button" onClick={handleClick}> child </button> ); } export function App() { const [foo, setFoo] = useState(0); const [bar, setBar] = useState(0); const [startTransition] = unstable_useTransition(); const handleClick = () => { setFoo((s) => s + 1); startTransition(() => { setBar((s) => s + 1); }); }; return <Child foo={foo} bar={bar} handleClick={handleClick} />; }
イベントハンドラのなかでfooとbarをインクリメントするのだが、fooの更新はstartTransitionでラップせず、barの更新はラップした。
この状態でボタンを押下すると、どうなるか。

最初のダイアログではfooのインクリメントのみが反映されており、2回目のダイアログでようやく、barのインクリメントが反映されている。
つまり、startTransitionを実行すると、ラップされていない状態更新が反映されたレンダリングを行い、その後、ラップされた状態更新も反映されたレンダリングを行う。
さらにここに、useTransitionの返り値の 2 番目の要素であるisPendingも組み合わせてみる。
function Child({ foo, bar, isPending, handleClick, }: { foo: number; bar: number; isPending: boolean; handleClick: () => void; }) { useEffect(() => { alert(`foo: ${foo}, bar: ${bar}, isPending: ${isPending}`); }); return ( <button type="button" onClick={handleClick}> child </button> ); } export function App() { const [foo, setFoo] = useState(0); const [bar, setBar] = useState(0); const [startTransition, isPending] = unstable_useTransition(); const handleClick = () => { setFoo((s) => s + 1); startTransition(() => { setBar((s) => s + 1); }); }; return ( <Child foo={foo} bar={bar} isPending={isPending} handleClick={handleClick} /> ); }

最初のダイアログではisPendingはtrue、2回目のダイアログではfalseになっている。
ここまでの内容をまとめると、次のようになる。
startTransitionを実行するとレンダリングが発生する- その際、
startTransitionでラップされている状態更新以外の更新が、反映される isPendingはtrueとして、レンダリングされる
- その際、
- 上記のレンダリングが終わると、再びレンダリングが行われる
- 今度は、
startTransitionでラップされている状態更新も反映される isPendingはfalseとして、レンダリングされる
- 今度は、
そして最後に、useTransitionの最大の特徴について説明する。
それは、2回目のレンダリング時にPromiseがスローされると、Suspenseがそれをキャッチするのではなく、1回目のレンダリングが表示され続け、Promiseの状態が変化した時点で改めてレンダリングを試みる、というものである。
以下のコードでそれを確認できる。
function countUp(arg: number) { let status = 'pending'; const suspender = new Promise((resolve) => { setTimeout(() => { resolve(null); }, 3000); }).then(() => { status = 'success'; }); return { get() { if (status === 'pending') { throw suspender; } return arg + 1; }, }; } function Child({ foo, bar, isPending, handleClick, }: { foo: number; bar: {get(): number}; isPending: boolean; handleClick: () => void; }) { const barValue = bar.get(); useEffect(() => { alert(`foo: ${foo}, bar: ${barValue}, isPending: ${isPending}`); }); return ( <button type="button" onClick={handleClick}> child </button> ); } const initialBar = countUp(-1); export function App() { const [foo, setFoo] = useState(0); const [bar, setBar] = useState(initialBar); const [startTransition, isPending] = unstable_useTransition(); const handleClick = () => { setFoo((s) => s + 1); startTransition(() => { setBar(countUp(foo)); }); }; return ( <Suspense fallback={<div>Fallback</div>}> <Child foo={foo} bar={bar} isPending={isPending} handleClick={handleClick} /> </Suspense> ); }

ボタンを押下するとまず、fooが更新された状態でレンダリングされる。これは先程までと同じ。
次にbarが更新された状態でレンダリングを試みるのだが、そうすると、const barValue = bar.get();の部分でPromiseがスローされる。
すると、Suspenseがキャッチしてフォールバックを表示する、のではなく、1回目のレンダリングが表示され続ける。つまり、fooのみが更新された状態のレンダリングが、そのまま表示され続ける。
そしてPromiseの状態が変化したとき(この例だと3秒後)に、barが更新された状態でのレンダリングを改めて試みる。今度はbar.get()がPromiseをスローしないので、問題なくレンダリングされる。
注意しなければならないのは、2回目のレンダリングではなく1回目のレンダリングでPromiseがスローされた場合、また異なった挙動になるということである。
1回目のレンダリングでPromiseがスローされると、それはSuspenseにキャッチされ、フォールバックが表示される。
そしてPromiseの状態が変化した際に改めてレンダリングが行われるのだが、その際にはstartTransitionでラップされた状態更新も反映した形で、レンダリングされる。isPendingもfalseになる。
handleClickを以下のように書き換えることで、確認できる。
const handleClick = () => { setBar(countUp(foo)); startTransition(() => { setFoo((s) => s + 1); }); };

ここまで説明してきた内容を踏まえて、改めてデータ取得の例を見てみる。
ボタンを押下すると、以下のコードが実行される。
const handleClick = () => { setNextId((id) => (id === 4 ? 1 : id + 1)); startTransition(() => { setResource(fetchUser(nextId)); }); };
setNextIdはstartTransitionでラップされていない。そのため、Next IDの変更はすぐに画面に反映される。
その後、setResourceによる更新を反映させてレンダリングを行おうとするが、ProfileコンポーネントがPromiseをスローする。そのため、先程のレンダリング結果(Next IDが更新された画面)をそのまま表示し続ける。そしてPromiseの状態が変化すると改めてレンダリングが行われ、新しいユーザの情報が画面に表示される。
また、Promiseの状態変化を待っている間はisPendingはtrueであるため、その間だけボタンの文字列はLoading....になる。

useTransitionによって実現されたこの挙動は、「レンダリングが並列的に行われている」と捉えることができる。
resourceが更新されたバージョンのProfileを準備しつつ、nextIdは更新されたがresourceが更新されていないバージョンのProfileを表示している。2 つのProfileが存在している。
この仕組みを上手く使うことで、不完全な状態の画面が表示されてしまうのを回避したり、逆に少しでも速くユーザの操作に対するフィードバックを返したり、といったことが可能になる。
useDeferredValue を使ったレスポンシブ性の向上
useTransitionの他にuseDeferredValueという Hooks が追加されており、こちらを使うことでも、状態の更新を遅延させることができる。
優先度が低い上に処理に時間がかかる更新を後回しにして、優先度が高い更新をできるだけ早く行って表示に反映させる。そうすることで、アプリのレスポンス性を高めることが企図されている。
stateをuseDeferredValueに渡すと、deferredValueを得られる。
そしてuseDeferredValueが存在する状態でstateを更新すると、まず、stateだけを更新した状態でレンダリングを行う。その後、deferredValueの値を更新後のstateと同じにした上で、またレンダリングを行う。
例を示す。
/// <reference types="react/experimental" /> import {Fragment, useState, unstable_useDeferredValue} from 'react'; export function App() { const [state, setState] = useState(1); const deferredValue = unstable_useDeferredValue(state); const countUp = () => { setState((s) => s + 1); }; useEffect(() => { alert(`state: ${state}, deferredValue: ${deferredValue}`); }); return ( <> <button type="button" onClick={countUp}> count up </button> <div>state: {state}</div> <div>deferredValue: {deferredValue}</div> </> ); }

まずstate:2, deferredValue:1でレンダリングを行い、その直後にstate:2, deferredValue:2でレンダリングを行っている。
上記の例ではuseEffectでダイアログを出していたので、レンダリングが2回行われたことを確認できた。
だがuseEffectを削除してしまうと、stateとdeferredValueがほぼ同時に更新されるため、違いを知覚できない。

レンダリングのための処理が重く、stateが頻繁に更新されると画面の更新が遅れてしまうような状況で、useDeferredValueは力を発揮する。
そのような状況では、処理が落ち着くまでuseDeferredValueの更新が遅延される。そのため、表示の更新が遅れても問題ないコンポーネントにはdeferredValueを渡してレンダリングを抑制し、優先的に表示を更新したいコンポーネントにのみstateを渡すことで、更新を遅れを減少させることができる。
優先的に表示すべき情報の典型例として、ユーザの操作に対するフィードバックがある。
例えば、テキストボックスに文字列を入力したら、その内容が即座に表示されることが求められる。
以下のテキストボックスは、文字列を入力しても反映までに時間が掛かってしまう。
/// <reference types="react/experimental" /> import {memo, useState} from 'react'; const ExpensiveComponent = memo(({text}: {text: string}) => { // わざと処理に時間がかかるようにしている const startTime = performance.now(); while (performance.now() - startTime < 120); if (text === '') { return ( <div> <i>Please input.</i> </div> ); } return ( <div> Text is <b>{text}</b>. </div> ); }); export function App() { const [text, setText] = useState('foo'); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setText(e.currentTarget.value); }; return ( <> <input value={text} onChange={handleChange} /> <ExpensiveComponent text={text} /> </> ); }

ExpensiveComponentの処理に時間がかかってしまっているのが原因。
ExpensiveComponentの処理が終わる前に次々とtextの更新が発生するため、表示の更新が追いつかない。ユーザによる入力が一段落して、ようやく表示が更新される。
このように、テキストボックスへの反映が遅れてしまうと、目に見えて操作性が悪くなる。
このとき、ExpensiveComponentの更新だけを遅延させることが許容されるなら、useDeferredValueを使うことで操作性を改善できる。
具体的には、以下のようにtextではなくdeferredTextをExpensiveComponentに渡すようにすればよい。
import {memo, useState, unstable_useDeferredValue} from 'react'; // 中略 export function App() { const [text, setText] = useState('foo'); const deferredText = unstable_useDeferredValue(text); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setText(e.currentTarget.value); }; return ( <> <input value={text} onChange={handleChange} /> <ExpensiveComponent text={deferredText} /> </> ); }
そうすると、ExpensiveComponentの更新にある程度の遅れが発生する代わりに、テキストボックスの更新は迅速に行われるようになる。

先程と同じようにtextは次々と更新されていくが、deferredTextは更新されず、textだけが更新された状態でレンダリングが行われていく。
そのため、ユーザのタイピングに合わせてinput.valueには最新のtextが次々と渡されるが、ExpensiveComponent.textには常に同じ値('foo')が渡される。
そしてExpensiveComponentはmemoでラップされているため、propsが変わらない限りは再レンダリングされない。
そのため、ExpensiveComponentによる重い処理の影響を受けることなくテキストボックスが更新されていき、textの更新が一段落した段階でdeferredTextが更新され、ようやくExpensiveComponentの再レンダリングが行われる。
今回のようなケースの他に、Promiseがスローされてレンダリングが中断された際にも、deferredValueの更新が遅延される。
復習を兼ねてまず、useDeferredValueを使わないパターンの挙動について見てみる。
/// <reference types="react/experimental" /> import {Suspense, useState, useEffect} from 'react'; function countUp(arg: number) { let status = 'pending'; const suspender = new Promise((resolve) => { setTimeout(() => { resolve(null); }, 2000); }).then(() => { status = 'success'; }); return { get() { if (status === 'pending') { throw suspender; } return arg + 1; }, }; } function Child({resource}: {resource: {get(): number}}) { const value = resource.get(); useEffect(() => { alert(value); }); return <span>{value}</span>; } const initialBar = countUp(-1); export function App() { const [foo, setFoo] = useState(0); const [bar, setBar] = useState(initialBar); const handleClick = () => { setFoo((s) => s + 1); setBar(countUp(foo)); }; return ( <> <button type="button" onClick={handleClick}> count up </button> <Suspense fallback={<div>Fallback</div>}> <div> bar: <Child resource={bar} /> </div> </Suspense> </> ); }
上記のコードの場合、count upボタンを押下するとPromiseがスローされ、レンダリングが中断される。
Promiseの状態が変化すると改めてレンダリングが試みられ、今度はresource.get()がPromiseをスローしないのでレンダリングに成功する。
そしてuseEffectが実行され、ダイアログが表示される。

これを書き換えて、ChildコンポーネントにbarではなくdeferredBarを渡すようにする。
import {Suspense, useState, useEffect, unstable_useDeferredValue} from 'react'; // 中略 export function App() { const [foo, setFoo] = useState(0); const [bar, setBar] = useState(initialBar); const deferredBar = unstable_useDeferredValue(bar); const handleClick = () => { setFoo((s) => s + 1); setBar(countUp(foo)); }; return ( <> <button type="button" onClick={handleClick}> count up </button> <Suspense fallback={<div>Fallback</div>}> <div> bar: <Child resource={deferredBar} /> </div> </Suspense> </> ); }
そうすると挙動が変わり、フォールバックが表示されなくなる。
そして、ボタンを押下する度にダイアログが2回表示されるようになっている。

PromiseがスローされるとdeferredBarの更新が遅延されるため、このような挙動になる。
ボタンを押下してもdeferredBarを更新せず、前回のレンダリング時と同じ値で、レンダリングを行う。
当然、resource.get()は前回と同じ値を返すため、レンダリングの結果は前回と変わらない。
そして、Promiseの状態が変化するとdeferredBarが更新される。そうするとChildも再レンダリングされるが、今度はresource.get()が新しい値を返すため、それを使ったレンダリングが行われる。
そのため、1回目のダイアログでは前回と同じ値が使われ、2回目のダイアログでは値が更新されているのである。