この記事は「Concurrent Mode時代のReact設計論」シリーズの5番目の記事です。
シリーズ一覧
- 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) まとめ(仮)
トランジションを軸に設計する
Concurrent Modeの目玉機能の一つであるuseTransitionについては、これまでの記事でも何度も触れてきました。Concurrent Mode時代のアプリ設計において最も厄介なのがこのuseTransitionであると言っても過言ではなく、まさにuseTransitionを制する者がConcurrent Modeを制するといえます。
今回は、useTransitionとは結局何なのか、useTransitionを設計に組み込むときの考え方について議論します。これまではuseTransitionは「startTransition関数とisPendingの値を得られるフック」と説明していました。機能面で見るとこれは間違いではありませんが、いまいち釈然としませんね。例えばuseStateなら「ステートを定義する」、useEffectなら「レンダリング後の副作用を定義する」といった、設計の側面から見たフックの効能を理解しなければなりません。
では、useTransitionは何をするためのフックなのでしょうか。筆者の考えでは、それはトランジションを定義することです。では、トランジションとは一体何なのでしょうか。
元来Reactにおいては、アプリのロジックの上では画面の変化はステートの更新という形に抽象化されていました。ここまで見たように、Concurrent Modeでは新たに「変化後の画面をすぐ表示できない(=サスペンドした)場合は前の画面にフィードバックを表示する」という要求を叶えられるようになります。このためにはReact本体によるサポートの一環として「サスペンド中の状態の検知」という機能が必要です。この部分を抽象化した概念がトランジションなのです。
ですから、useTransitionの恩恵を受けるアプリを作るためにはトランジションを組み込んだ設計が必要となります。
トランジションの所有者
一般に、フックの呼び出しというのはコンポーネントに属するものです。例えばuseStateなどは最もわかりやすい例で、これはそれが使われたコンポーネントに属するステートを宣言するものです。もちろん、useTransitionも例外ではありません。つまり、トランジションはコンポーネントに属するのです。実際、useTransitionを使うとisPendingという真偽値が得られるのですから、そのisPendingを取り扱うのはuseTransitionを使ったコンポーネントの責任です。
ある意味で、useTransitionはuseStateに近いフックです。それは、isPendingというある種の状態を管理するフックだからです。そうなると、isPendingという状態は誰が管理するのか、言い換えればどのコンポーネントがuseTransitionを呼び出すのかという問題が発生することになります。
末端のコンポーネントがuseTransitionを呼ぶのか、それとも上層のコンポーネントにまとめるのかというのは難しいテーマであり、筆者もまだ結論を出せてはいません。昨今のステート管理ライブラリはアプリのステートをコンポーネント間で分散させるのではなく一箇所のストアにまとめるという戦略を取っていることが多いですが、Concurrent Mode時代のステート管理ライブラリはそれに加えてuseTransitionも一緒に管理してくれるのかもしれません。ただ、複数のトランジションを定義するには複数回useTransitionを呼び出すしかないので、上手に管理するのは難しそうですね。
React公式の考え方
Reactの公式ドキュメントには、「ボタンのコンポーネントがuseTransitionを呼び出す」という例があります。このButtonコンポーネントはuseTransitionを呼び出してstartTransition関数を取得し、ボタンがクリックされたときに与えられたonClickをstartTransitionで包んで呼び出します。
// ドキュメント (https://reactjs.org/docs/concurrent-mode-patterns.html) から引用
function Button({ children, onClick }) {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
function handleClick() {
startTransition(() => {
onClick();
});
}
// ...
return (
<>
<button onClick={handleClick} disabled={isPending}>
{children}
</button>
{isPending ? spinner : null}
</>
);
}
Buttonに渡されたonClickによって行われたステート更新によってサスペンドが発生した場合、useTransitionの効果によりステート更新前の状態がレンダリングされます。ただし、その際useTransitionが返すisPendingがtrueになることから、ボタンはdisabledの状態になります。この状態はサスペンドが解消するまで(サスペンド時に投げられたPromiseが解決するまで)続きます。
話を戻すと、つまりこのButtonコンポーネントはトランジションを内包しており、onClickで与えられたステート変化を自動的にトランジションに包んで行なってくれるうえに、サスペンド中の表示まで面倒を見てくれるというたいへん便利なコンポーネントだということです。コンポーネントを使う側は、useTransitionのことなど意識せずにこのButtonコンポーネントをボタンとして使うだけでuseTransitionの恩恵を受けることができます。
公式ドキュメントに載っているだけあって、これもuseTransitionのための設計におけるひとつの解です。しかしながら、これはあくまでお手軽な例であって、より複雑なアプリケーションに適用できる設計ではないというのが筆者の考えです。その理由は、この設計は以下に示す2つの問題点を抱えているからです。
問題点1: トランジションとステート更新が分離している
関数startTransitionは、コールバック中でステート更新を行なって初めて意味を持ちます。一方で、ButtonコンポーネントはstartTransitionのコールバック中でonClickを呼び出します。
つまり、Buttonコンポーネントは暗黙のうちに「onClickを呼び出すとステート更新が発生する」ことを要求しています。このことがインターフェースからは見えづらくなっています。言い方を変えれば、startTransitionの呼び出しとその中でのステート更新という、本来一体となって書かれるべき処理がButtonコンポーネントの内側と外側に分かれてしまっています。
ロジックを分割するのは良いこととはいえ、セットで意味を持つものを分断するのは逆にプログラムを分かりにくくするばかりで良くありません。
ただ、startTransitionは中でステート更新を行わなくてもエラーなどが起きるわけではなく、ただ何も起きないだけです。なので、onClick内でステート更新を行わないのが有害なことかと聞かれればそうでもありません。
問題となるのは例えば「startTransitionの中で何か非同期処理を行なったあとにステート更新を行う」というような場合で、非同期処理を挟んだ時点でstartTransitionの中という扱いではなくなるため、この場合は期待した動作とはならないでしょう。Concurrent Mode時代にそんなやり方で非同期処理を行うべきではないと言われれば返す言葉がありませんが、それはアプリ全体の設計に関わる話であり、こんな末端のutilityコンポーネントがアプリ全体の設計を意識しないと使えないというのはどうにも微妙です。
問題点2: startTransitionの中と外の併用ができない
ステート更新の全てをstartTransitionの中で行うのではなく、中と外の両方でステート更新を行いたいという場合もあります。
外で行われた更新はサスペンドしてもuseTransitionのサポート対象となりませんが、その代わりにステート更新が即座に反映され、サスペンド中のレンダリング(isPendingがtrueになった状態)にも反映された状態になります。
ですから、ユーザーへのフィードバックを素早く返しつつ非同期処理を行うというユースケースでは、フィードバックを返すために必要なステート更新はstartTransitionの外で行い、非同期処理に相当するステート更新はstartTransitionの中で行う必要があります。
先ほどのButtonコンポーネントではonClickは問答無用でstartTransitionの中に入れられるため、このようなユースケースに対応できません。
中と外の併用例
「startTransitionの中と外の両方でステートを更新するなんて、そんな面倒なケース本当にあるの?」と思われた読者の方もいるかもしれませんので、ひとつ例をお出しします。
最初の記事で宣伝したアプリでは、穴が空いたTypeScriptプログラムが提示され、ユーザーが全ての穴を埋めると正誤が判定されます。下のスクリーンショットは正しく穴を埋めた状態の表示で、プログラムの背景が緑色になると共に「NEXT」ボタンが表示されます。
正誤の判定はWorker上で動くTypeScriptコンパイラが行い時間がかかるため、非同期処理となります。ユーザーが最後の穴を埋めた瞬間に正誤判定処理を表すFetcherが作成されます。このFetcherは当然ステートに入れられますが、非同期処理であるためこのときの更新はstartTransitionの中で行う必要があります。実際、アプリでは正誤判定中に「evaluating...」という表示を行うためにuseTransitionを使用しています。
一方、ユーザーが穴を埋めるという操作を行なった(プログラム中の穴を選択した状態で下の選択肢をクリックした)場合、穴が埋まるというUI上のフィードバックはなるべくすぐに行う必要があります。つまり、ユーザーが正誤判定を待っている間、画面は穴が全部埋まった状態で「evaluating...」と表示されることになります。
そのためには、穴を埋めるためのステート更新はstartTransitionの外で行う必要があります。もし中に入れてしまうと、正誤判定が終わるまで画面に反映されず、ユーザーは選択肢をクリックしたあと画面が無反応のまま待たされることになります。
以上のことから、ユーザーが選択肢をクリックした瞬間のステート更新はstartTransitionの中と外の両方で行う必要がありました。
なお、フィードバックといっても、startTransitionの外で行なったステート更新は当然ながらサスペンド終了後も残り続けます。今回は、ユーザーが埋めた穴は正誤判定完了後も残り続けるのが正しいのでこれで問題ありません。あくまでサスペンド中にのみ一時的に表示したいという場合はisPendingに頼ることになります。
Buttonコンポーネントの改善案
では、話をButtonコンポーネントに戻しましょう。このコンポーネントが持つ2つの問題のうち、特に問題点2(startTransitionの中と外を併用したステート更新ができない)が問題であり、Buttonコンポーネントの使途を大きく制限しています。
これをどう解決すべきかという話は、結局useTransitionをどう設計に組み込むべきかという話そのままです。ですから、唯一解があるような話ではないのですが、とりあえず一つ案を出してみます。
それは、onClickにコールバックでstartTransitionを渡すというものです。この方針では、トランジションの所持者をButtonにしたままで前述の2つの問題を解決します。具体的にはこのようなコードになるでしょう。
// ドキュメント (https://reactjs.org/docs/concurrent-mode-patterns.html) から引用
function Button({ children, onClick }) {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
function handleClick() {
onClick(startTransition);
}
// ...
return (
<>
<button onClick={handleClick} disabled={isPending}>
{children}
</button>
{isPending ? spinner : null}
</>
);
}
ポイントはhandleClickで、中身はonClick(startTransition);となっています。このことから分かるように、Buttonを使う側はこのような使い方をすることになります。
<Button onClick={(startTransition)=> {
startTransition(()=> {
setState(new Fetcher(()=> ...));
});
}}>load something</Button>
こうすることで、前述の「startTransitionとステート更新がButtonの内外に分散している」及び「startTransitionの中と外を併用したステート更新ができない」という2つの問題点が解消されていますね。Buttonコンポーネントの役割も「トランジションを内包しており、ボタンクリック時にそのトランジションを用いてステート遷移をすることができる」とより明確なものになりました。
トランジションを持つのがButtonでいい(絶対にButtonの中でしかフィードバックを返さないという強い意志がある)ならこの設計もありでしょう。
一方で、より広範囲にわたってフィードバックを表示したい場合は、その全体をカバーするためにより上流にuseTransitionを配置しなければいけません。
どこにどれだけuseTransitionを置くか
繰り返しますが、useTransitionにより宣言されるトランジションはコンポーネントに属するものです。そして、useTransitionがisPendingを返すことから、トランジションを所持するコンポーネントは必然的にそのトランジション内で発生したサスペンドに際してユーザーに適切なフィードバックを返す責任を負います。
ですから、その責任をどのコンポーネントが負うのかを考えてuseTransitionを配置する必要があります。これがトランジションに係るアプリ設計の基本です。もうひとつ、startTransition関数を入手できるというのもuseTransitionの機能ですが、こちらはコンポーネントツリーの上へも下へも受け渡しがしやすいものです。上に渡すのは先ほど見たButtonの改善案のような感じです。下に渡すほうが簡単なのはいうまでもありませんね。一方で、isPendingを上に受け渡すのは不可能です。ですから、フィードバックをレンダリングするコンポーネントかそれより上にuseTransitionを配置しなければいけません。
トランジションとステート更新はセットで扱われることや、isPendingもステートに似た性質を持つものであることから、useTransitionの配置の方針はステート(useStateやuseReducer)の配置の方針と似たものになります。ステートを上の方の1箇所で管理する(useReducerを使う場合などはこれになりがちですね)場合はそこにuseTransitionも配置するのが素直な設計です。一方で、ステートを分散させる方針の場合はuseTransitionも適宜分散させることが考えられます。
また、Reduxなどを使用する場合にありがちな、ステートを一元管理するような設計の場合でも、ちょっとしたフラグなどは末端のコンポーネントが持ってよいという説もあります。トランジションの最中かどうかを表すisPendingもこれに近いところがありますから、必ずしもコンポーネントツリーの上の方に持っていく必要は無いかもしれません。トランジションもまた、ステート管理にまつわる終わりのない議論に組み込まれることになるということです。
また、複数の異なるトランジションを取り扱うためには複数のuseTransition呼び出しが必要です。というのも、useTransitionから返されるstartTransitionとisPendingは実はペアになっています。すなわち、startTransition中でサスペンドが発生した場合、同じuseTransitionから返されたisPendingがtrueになります。
場合によって異なるフィードバックを使い分けたい場合は、トランジションを複数用意して使い分けることになります。このことから分かるのは、トランジションの本質がisPendingを見てフィードバックを表示するところにあるということです。トランジションの設計とは、どのような場合にどのようなフィードバックが発生するかを整理して、それぞれに対してトランジションを定義することなのです。
別の言い方をすれば、我々は同じ種類のステート更新をするときは同じトランジションを用い、違う種類のステート更新をするときは違うトランジションを用いるべきであるということになります。
例えば、Reactアプリの典型的なトランジションの一つはページ遷移です。多くの場合、ページ遷移の待機中はどこからどこへのページ遷移かに依らずに同じようなフィードバックが返されます。例えばGitHubで画面上部にローディングバー的なものが出るようなイメージです。それを実現するためには、どのページ遷移にも同じトランジションを用いるべきです。ここから言えるのは、ページ遷移のトランジションは必然的に、各ページの中でなくより上位の(アプリ全体を統括する)コンポーネントで定義されるということです。
一方で、先ほどのアプリの例にあったような正誤判定を行うというトランジションの場合、それが発生するのは問題表示ページの中なので、ページ内にトランジションを定義することができます(もちろん、場合によってはより上に持っていくこともできるでしょう)。
トランジションとステート更新の直交性
トランジションという概念が優れている点は、ステート更新との間に直交性があることです。つまり、サスペンドを伴うステート更新を行う際に「どのようなステート更新を行うか」と「どのトランジションを用いるか」の間に依存関係が無く、両者をそれぞれ自由に選ぶことができます。どのステート更新をする際にどのトランジションを使っても良いのです。逆に言えば、Concurrent Modeにおいては画面を更新する際には、ステートの更新ロジックに加えてトランジション(より具体的にはstartTransition関数)を調達しなければならないということです。
この直交性より、「非同期処理中は前の画面に留まる」という処理を「ステート更新(及びSuspenseによるサスペンドのサポート)」と「トランジション」の2つの要素に疎結合な形で分解することに成功しています。
例えば、複数の種類のステート更新に際して、同じトランジションを利用することができます。その典型例はやはりページ遷移の場合であり、どのページからどのページに遷移する場合でも同じトランジションを使うのが普通でしょう。同じトランジションというのは結果としては同じフィードバックエフェクトということになりますが、これを単なる「コードの共通化」ではなく「(実体として)同じトランジション」という一段上の抽象度で表現できることは特筆に値します。APIデザインの妙と言えるでしょう。
設計の具体例
ここまでをまとめると、useTransitionはトランジションを定義するフックであり、アプリの設計に当たってはuseTransitionをコンポーネントツリーの中のどこで呼び出すかをuseStateと同様の観点から考えるべきであるということでした。
前回までに出てきたRootとかPageAとかの例を見直してみましょう。2つ前の記事ではRootはこのように定義されていました。
export const Root: FunctionComponent = () => {
const [state, setState] = useState<AppState>({
page: "A"
});
const goToPageB = () => {
setState({
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
});
};
return (
<Suspense fallback={null}>
<Page state={state} goToPageB={goToPageB} />
</Suspense>
);
};
const Page: FunctionComponent<{
state: AppState;
goToPageB: () => void;
}> = ({ state, goToPageB }) => {
if (state.page === "A") {
return <PageA goToPageB={goToPageB} />;
} else {
return <PageB usersFetcher={state.usersFetcher} />;
}
};
ポイントは、画面Bに遷移するためのステート更新を行う関数goToPageBをRootが定義していることです。今いるページを表すステートをRootが持っているのでこれは自然ですね。
次にPageAはこのようになっていました。
const PageA: FunctionComponent<{
goToPageB: () => void;
}> = ({ goToPageB }) => {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
return (
<p>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
goToPageB();
});
}}
>
{isPending ? "Loading..." : "Go to PageB"}
</button>
</p>
);
};
PageAはuseTransitionを呼び出すことでトランジションを定義しています。これは何のトランジションかというと、言わずもがな、画面Aから画面Bへの遷移のためのトランジションです。画面遷移時は、PageA内で定義したトランジション内でgoToPageBを呼び出すことで画面Bへの遷移を行います。サスペンド中は画面A内のボタンが「Loading...」という表示になります。
この設計は、この記事の内容に照らせば微妙な設計です。なぜなら、startTransitionとsetStateという、本来セットで扱われなければならない2つの関数がコンポーネント境界によって分断されているからです。
これに対しては2つの方策が考えられます。一つはuseTransitionをRootに移すもの、もう一つはuseTransitionをPageAの中に置いたままにするものです。先ほど述べたように、ページ遷移のためのトランジションであることを考えるとRootに移すのが手であるように思えます。その一方で、今回トランジションに反応するのがPageA内のボタンであるという事情から、useTransitionをPageA内に残す方向性にも一理あります。
まずは前者から見ていきましょう。
RootにuseTransitionを移す場合
この場合はRootはこんな感じになります。
export const Root: FunctionComponent = () => {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
const [state, setState] = useState<AppState>({
page: "A"
});
const goToPageB = () => {
startTransition(() => {
setState({
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
});
});
};
return (
<Suspense fallback={null}>
<Page state={state} isPending={isPending} goToPageB={goToPageB} />
</Suspense>
);
};
const Page: FunctionComponent<{
isPending: boolean;
state: AppState;
goToPageB: () => void;
}> = ({ state, isPending, goToPageB }) => {
if (state.page === "A") {
return <PageA isPending={isPending} goToPageB={goToPageB} />;
} else {
return <PageB usersFetcher={state.usersFetcher} />;
}
};
RootがuseTransitionを呼び出して、得られたstartTransitionはgoToPageBに組み込まれました。これにより、goToPageBの中ではstartTransitionとsetStageがセットで扱われるようになり、設計上の問題点が解消されました。また、サスペンド中のフィードバック描画をPageAが担う関係で、isPendingはPageAに渡されています。
この設計ではPageAの中身が薄くなりますが、一応示しておくとこういう感じです。
const PageA: FunctionComponent<{
isPending: boolean;
goToPageB: () => void;
}> = ({ isPending, goToPageB }) => {
return (
<p>
<button disabled={isPending} onClick={goToPageB}>
{isPending ? "Loading..." : "Go to PageB"}
</button>
</p>
);
};
このように、RootにuseTransitionを移した場合はロジックがRootに偏重する形になります。昨今の風潮からするとこれは悪いことではありません。PageAにstateとisPendingを別々に渡しているのが微妙に思えた方もいるかもしれませんが、まあ適当なオブジェクトに詰め込むことも可能でしょう。
また、こうしてみると将来のステート管理ライブラリがuseTransitionも組み込んだ設計になりそうな予感がしてきます。そうすれば、isPendingを自然にステートに混ぜ込んで扱うことができるかもしれません。
PageAにuseTransitionを残す場合
もう一つ考えられるのはuseTransitionをPageAに残す場合です。この場合は、先ほどReact公式ドキュメントのButtonコンポーネントの例で議論したのと同じテクニックが使えます。つまり、goToPageBにstartTransitionを渡すのです。具体的に実装すると、以下のようになります。まずRootはこうです。Rootが定義するgoToPageBはstartTransitionを引数として受け取り、それを使用します。つまり、goToPageBは「与えられたトランジションの上で画面Bへのページ遷移を行う」という関数になります。
export const Root: FunctionComponent = () => {
const [state, setState] = useState<AppState>({
page: "A"
});
const goToPageB = (startTransition: React.TransitionStartFunction) => {
startTransition(() => {
setState({
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
});
});
};
return (
<Suspense fallback={null}>
<Page state={state} goToPageB={goToPageB} />
</Suspense>
);
};
const Page: FunctionComponent<{
state: AppState;
goToPageB: (startTransition: React.TransitionStartFunction) => void;
}> = ({ state, goToPageB }) => {
if (state.page === "A") {
return <PageA goToPageB={goToPageB} />;
} else {
return <PageB usersFetcher={state.usersFetcher} />;
}
};
次に、PageAはこのようになります。
const PageA: FunctionComponent<{
goToPageB: (startTransition: React.TransitionStartFunction) => void;
}> = ({ goToPageB }) => {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
return (
<p>
<button disabled={isPending} onClick={() => goToPageB(startTransition)}>
{isPending ? "Loading..." : "Go to PageB"}
</button>
</p>
);
};
PageA内でuseTransitionを用いてstartTransitionを取得し、それをgoToPageBに渡しています。つまり、PageAがトランジションを定義し、goToPageBに使ってもらっています。
こちらの設計はisPendingをPageAが持ってほしい場合に有利です。先ほども述べたように、このような設計にも一定の合理性があります。
筆者は、個人的にはこちらの設計のほうが好みです。その理由は、こちらの方がトランジションとステート更新の直交性を活かす余地があるからです。つまり、goToPageBは、PageAが定義したトランジションだけでなく、他のトランジションと組み合わせて使う余地があります。例えば、画面CからBに遷移したいときは画面Cの中で定義されたトランジションを使うということも、同じgoToPageBを用いて可能になります。
さらに言えば、次のような実装によりgoToPageBに「デフォルトのトランジション」を用意することすらできるでしょう。
export const Root: FunctionComponent = () => {
const [state, setState] = useState<AppState>({
page: "A"
});
const [startDefaultTransition, isPending] = useTransition();
const goToPageB = (
startTransition: React.TransitionStartFunction = startDefaultTransition
) => {
startTransition(() => {
setState({
page: "B",
usersFetcher: new Fetcher(() => fetchUsers())
});
});
};
return (
<Suspense fallback={null}>
<Page state={state} goToPageB={goToPageB} />
</Suspense>
);
};
こうすることで、goToPageBはデフォルトではRootがあらかじめ用意しておいたトランジションを用いるが、goToPageBを使う側が用意した別のトランジションを使うこともできるというなかなか高性能な関数になります。
まとめ
今回はuseTransitionをどのように使うかについて議論しました。この記事では、そもそもuseTransitionはトランジションを定義するものであると位置付けています。Reactの公式ドキュメントでも、useTransitionはステートの更新をトランジションで包むためのものであるという説明があります。
トランジションはConcurrent Modeの目玉機能であり、サスペンドという部分は比較的分かりやすくて設計に議論の余地がないことも踏まえると、トランジションの設計がアプリの動作設計の軸に添えられがちです。
どのコンポーネントがトランジションを定義すべきかという点については、基本的にはステートと同じ考え方が通用します。ローディング中のフラグ用にステートを定義するのと同じ気持ちでトランジションを定義するのがよいでしょう。
気をつけるべきことは、トランジションとステート更新をむやみに分離しないことです。ただし、その一方でトランジションとステート更新の直交性を活かすことも重要です。筆者お勧めのパターンは、記事中で何度か示したようにstartTransitionを受け渡すパターンです。
次回の記事はアプリ設計に残る最後の謎である「副作用」についての話です。