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
回目のダイアログでは値が更新されているのである。