この記事は「Concurrent Mode時代のReact設計論」シリーズの2番目の記事です。
シリーズ一覧
- Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理
- Concurrent Mode時代のReact設計論 (2) useTransitionを活用する
- Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか
- Concurrent Mode時代のReact設計論 (4) コンポーネント設計にサスペンドを組み込む
- Concurrent Mode時代のReact設計論 (5) トランジションを軸に設計する
- Concurrent Mode時代のReact設計論 (6) Concurrent Modeと副作用
- Concurrent Mode時代のReact設計論 (7) ステート管理ライブラリの展望(仮)
- Concurrent Mode時代のReact設計論 (8) まとめ(仮)
useTransitionを活用する
前回の記事ではConcurrent Modeの基礎的な機能と、それを扱うための考え方を説明しました。ボタンを押すとステートにFetcherが突っ込まれて、それにより再レンダリング・サスペンドが発生するという流れでした。
実は、その例ではサスペンドが発生した際に次のようなワーニングが発生します。
Warning: Container triggered a user-blocking update that suspended.
The fix is to split the update into multiple parts: a user-blocking update to provide immediate feedback, and another update that triggers the bulk of the changes.
Refer to the documentation for useTransition to learn how to implement this pattern.
これは、ボタンのonClickのようにユーザーの操作をきっかけとして、再レンダリング→サスペンドが発生したときに表示されるワーニングです。これが意味するところを噛み砕いて説明すると、「ユーザーの入力に対してはすぐにフィードバックを返すべきだから、サスペンドする(=新しいステートが表示されるまでに時間がかかる)のは良くない」ということです。
そして、このワーニングに対する対処法はずばり**useTransitionを使うこと**です。useTransitionを使うことで、ステートの更新でサスペンドが発生した場合に元々のステートを基にフィードバックを描画できるのです。
useTransitionの使用例
さっそく、先ほどの例にuseTransitionを追加してみましょう。useTransitionはユーザーへのフィードバックを念頭に置いた機能なので、ユーザーへのフィードバックとしてボタンを押したらローディング中はボタンがdisabledになるという実装を入れてみましょう。Containerをこのように変更します。
const Container: FunctionComponent = () => {
// useTransitionの呼び出しを追加
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
const [usersFetcher, setUsersFetcher] = useState<
Fetcher<User[]> | undefined
>();
return (
<>
<p>
<button
onClick={() => {
// ステート更新をstartTransitionで囲む
startTransition(() => {
setUsersFetcher(new Fetcher(fetchUsers));
});
}}
// isPendingがtrueのときはdisabledに
disabled={isPending}
>
{isPending ? "Loading..." : "Load Users"}
</button>
</p>
<Suspense fallback={<p>Loading...</p>}>
{usersFetcher ? <UserList usersFetcher={usersFetcher} /> : null}
</Suspense>
</>
);
};
useTransitionはフックの一種なので、このように関数コンポーネントから呼び出します。結果はstartTransition関数とisPending(真偽値)の組です。このstartTransitionはボタンのonClickハンドラの中で使われており、ステートの更新がstartTransitionで囲われています。startTransitionに渡されたコールバック関数は即座に呼び出されます。
この実装では、ボタンを押すと以下のスクリーンショットのような挙動となります。
これを理解するために。useTransitionの挙動を簡単に説明します。startTransitionの内部で行われたステートの更新がサスペンドを発生させた場合、変更後ではなく変更前のステートがレンダリングされます。ただし、このとき変更前のステートではuseTransitionが返すisPendingがtrueになっています。投げられたPromiseが解決された場合は変更後のステートで再レンダリングされます。
useTransitionを使わない場合との違いはサスペンド中に現れます。useTransitionを使わない場合はSuspenseによるフォールバックが表示されますが、useTransitionを使う場合はフォールバックは表示されず、代わりにステート更新前の状態が(isPendingがtrueで)レンダリングされるのです。
useTransitionにオプションとして渡したtimeoutMsは、この「isPendingがtrueの状態」の最大持続時間を表します。この時間が過ぎてもPromiseが解決されなかった場合、諦めて変更後のステートがレンダリングされます。ただし、まだPromiseが解決されていないのでSuspenseによりフォールバックが表示されます。
ボタンがクリックされてからの流れは次のようになります。
- 初期状態では、
usersFetcher=undefined, isPending=falseである。(上のスクリーンショットの左の状態) -
startTransition内でsetUsersFetcherが呼ばれ、usersFetcherステートが更新される。(このときnew Fetcherで作られたオブジェクトをFとする) -
useTransitionの効果ににより、まずusersFetcher=undefined, isPending=trueの状態でContainerがレンダリングされ、DOMに反映される。(上のスクリーンショットの真ん中の状態) - 次に、新しいステート(
usersFetcher=F, isPending=false)でContainerがレンダリングされる。これはUserListのレンダリングに繋がり、UserListのレンダリングはサスペンドする。useTransitionの効果により、この状態はDOMに反映されない。 -
Fが持つPromiseが解決されると、新しいステート(usersFetcher=F, isPending=false)でContainerが再レンダリングされる。今回はサスペンドが発生せずにレンダリングが完了し、この状態がDOMに反映される。(スクリーンショットの右の状態)
ポイントは、useTransition内でステートの更新を行なった場合、新しいステートよりも「元のステート+isPending=true」のレンダリングが優先されるということです。これは、isPending=trueの状態でユーザーへのフィードバックを表すことを意図しているためです。ユーザーへのフィードバックは最優先で画面に反映されるべきであるため、これが最初に処理されます。
ちなみに、startTransitionの中と外の両方でステートの更新を行うことができます。この場合、startTransitionの外で行なった更新は3の段階で反映されています(もちろん5の段階にも反映されます)。
また、timeoutMsで設定した時間を超えない限り、Suspenseのfallbackで指定した内容は表示されなくなります。useTransitionをきちんと使っている限りは、Suspenseのfallbackはいわば最終防衛ラインのような扱いになり、高頻度でユーザーが目にするものではなくなります。
useTransitionの必要性
Concurrent Modeにおける設計ではPromiseをステートに持つことになると前回述べましたが、この立場ではuseTransitionの存在は必然的なものとなります。
そもそも、アプリの状態・画面表示といったものの変化は、Reactにおいてはステートの変化として表されます。ステートの変化によって起こることは再レンダリングです。そして、非同期処理によって発生するサスペンドは、再レンダリングの結果として起こります。
ということは、当然ながら、ステートを更新しないとサスペンドが発生しないということです。ステートを更新するということは、(Suspenseによるフォールバックになるかもしれませんが)新しい画面がレンダリングされるということであり、そうなると普通は古いステートは捨てられます。
しかし、これは時に問題となります。例えば、「画面Aから別の画面Bに遷移したい。ただし、画面Bを表示するには非同期処理によるデータの読み込みが必要」という場合を考えてみましょう。しかも、データの読み込み中は画面Aに留まって読み込み中の表示にしたいとします。このとき、非同期処理が完了し次第画面Bに遷移するようにするには、とにかく画面Bをレンダリングしてサスペンドさせる必要があります。しかし画面Bをレンダリングしてしまうと画面Aは消えてしまいます。
この問題に対して、useTransitionは「古い状態(画面A)と新しい状態(画面B)を同時に扱う」という方法で対処します。これはちょうど、gitでブランチを切って2つのバージョンのステートをメンテナンスするようなものです(Reactの公式ドキュメントでもこの例えが用いられています)。これによって、「まだ画面には反映されないけど新しいステートをレンダリングする」ということが可能になりました。
まとめ
この記事ではReactが発するワーニングをきっかけとしてuseTransitionを導入しました。Promiseをステートに入れるという設計方針をとったとき、useTransitionは欠かせない部品となります。
次回は、なぜそこまでしてPromiseをステートに入れたいのかについて議論します。
次の記事: Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか
