この記事は「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が何を解決するか