Concurrent Modeは、現在(2020年3月)実験的機能として公開されているReactの新しいバージョンです。Reactの次のメジャーバージョン(17.x)で正式リリースされるのではないかと思っていますが、確証はありません。なお、React公式からもすでに結構詳細なドキュメントが出ています。
Concurrent Modeに適応したアプリケーションを作るためには、従来とは異なる新しい設計が必要となります。筆者はConcurrent Modeを使ったアプリケーションをひとつ試作してみました。この記事から始まる「Concurrent Mode時代のReact設計論」シリーズでは、ここから得た知見を共有しつつ、Concurrent Mode時代に適応したReactアプリケーションの設計を提案します。
なお、Concurrent Modeはまだ正式リリース前の機能です。今後正式リリースまでの間にAPIの変更などが発生してこの記事の内容が当てはまらなくなる可能性は否定できませんが、その際はご容赦ください。
ちなみに、作ったアプリケーションはこれです。(宣伝)
プルリクエストも大募集しています。問題の追加はConcurrent Modeを理解していなくても大丈夫です。(宣伝)
シリーズ一覧
- 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) まとめ(仮)
現在は(5)まで公開済です。
イントロダクション
Concurrent ModeにおいてはReactの内部の実装が変更され、レンダリングの中断・再開をサポートするようになります。これにより、ユーザーの入力により素早く反応するなど、ReactアプリケーションのUX向上が期待できます。
Concurrent Modeは、useTransitionに代表される新しいAPIを搭載しており、Concurrent Modeを完全に活かすには新しいAPIを使いこなさなければいけません。useTransitionについては筆者の以前の記事が詳しいので、気になる方は合わせてお読みください。この記事の理解に必須ではありません。
冒頭で述べた通り、このシリーズでは筆者がConcurrent Modeを試してみた経験を基にして、Concurrent Mode時代に適応したReactアプリケーションの設計を提案します。もちろんこれが唯一解であると主張したいわけではありませんが、最も基本的な考え方として通用するものだと考えています。
なお、このシリーズではステート管理やデータフェッチング用の外部ライブラリを使わない、最も基本的なConcurrent Mode向け設計を議論します。これから先Concurrent Modeに適応したライブラリが増えることと思いますが、そのライブラリを使う場合はまた異なる設計となるかもしれない点はご了承ください。まあライブラリを使うかどうかで設計が変わるのは当たり前の話ですが。
なお、実際に手を動かしながら読みたいという方向けに、TypeScript + React Concurrent Modeの設定がしてあるCodeSandboxを用意してあります。適当にいじって試してみましょう。
非同期処理の扱い方が変わる
React Concurrent Modeの最大の特徴として「Promiseをthrow
する」という衝撃的な仕様のみを知っていたという方も多いでしょう。Promiseというのは、非同期処理を表すのに非常に広く使われるオブジェクトです。
レンダリング時にPromiseをthrow
するには、コンポーネントがPromiseを持っている必要があります。コンポーネントがPromiseを持つ場合の選択肢は主にステートに持つ(useState
とか)かrefで持つ(useRef
)のどちらかです。もちろんpropsやuseContext
で受け取ることもできますが、それは親のコンポーネントが何らかの手段でPromiseを調達しているので本質的にはやはり前記のどちらかです。
一般に、レンダリング結果に関わるものをuseRef
で持つのは良くありません(後述しますが、Concurrent Modeではこれまで以上にこれを厳守する必要があります)。よって、Promiseをステートに持つことが必要になります。ただ、実際には生のPromiseでは機能不足なので、適当なラッパーを作ることになります(あとで具体例が出てきます)。
Promiseをステートに持つことで、コンポーネントは**「非同期処理の途中」というステートをもはや表現する必要がなくなります**。それは「レンダリングの中断(サスペンド)」で表せば良いのですから。つまり、例えば「データがあればロード済、データが無ければロード中」のようなロジックをコンポーネントが持つことは無くなります。
言い換えれば、コンポーネントはデータがロード中の場合の処理を気にする必要が無くなります。ただし、実際には「レンダリングの中断」の場合を別の場所(Suspense
のフォールバック、あるいはuseTransition
のトランジション中状態)でハンドリングする必要がありますから、非同期処理について全く考えなくていいわけではありません。その意味では、より正確に言えばConcurrent Modeは非同期処理の扱いをより疎結合に表現する手段を提供してくれるというところでしょう。従来我々が手ずから扱っていた非同期処理対応の一部分を、Reactが組み込みの機能として受け持ってくれるという見方もできます。
Concurrent Modeにおける非同期処理
では、改めてConcurrent Modeにおける非同期処理について説明します。
Concurrent Modeでは、コンポーネントがPromiseを投げることでサスペンド(レンダリングの中断)を表すことができます。その場合、当該のPromiseが解決されたら再度レンダリングが試みられます。まだ、サスペンドが発生したときに代替のビューを提供する機能が提供されます(Suspense
やuseTransition
)。
これらの機能を使うことで、Concurrent Modeではより宣言的に非同期処理を扱えるようになったと言えます。ただし、同時にこの機能はReactと非同期処理をより密結合なものにするという側面を持ち合わせています。その意味で、ReactやConcurrent Modeでよりopinionatedなライブラリになったと言えます。
まずは、Concurrent Modeにおける基本的な非同期処理の例を示します。例を通してConcurrent Modeの感覚を掴みましょう。
まず、先ほど少し言及したPromiseのラッパーを定義します。
PromiseをラップするFetcher<T>
Fetcher<T>
という名前は我ながら微妙な気がするのですが、いい命名が思いつかないので募集中です。Fetcher<T>
は内部にPromiseを持っており、さらにPromiseが現在どういう状態なのか(State<T>
)を知っています。これにより、「Promiseがまだ解決されていなかったらそのPromiseを投げる」という、Promiseの現在の状態に基づく分岐を実装しています。
type State<T> =
| {
state: "pending";
promise: Promise<T>;
}
| {
state: "fulfilled";
value: T;
}
| {
state: "rejected";
error: unknown;
};
このState<T>
型はPromiseの3つの状態(解決前、成功、失敗)を表現する型です。解決前の場合はそのPromiseを、成功済みの場合は結果の値(T
型)を、そして失敗の場合はエラーの値を保持します。このState<T>
を用いて書かれたFetcher<T>
の実装は以下の通りです1。
export class Fetcher<T> {
private state: State<T>;
constructor(fetch: () => Promise<T>) {
const promise = fetch().then(
value => {
this.state = {
state: "fulfilled",
value,
};
return value;
},
error => {
this.state = {
state: "rejected",
error,
};
throw error;
},
);
this.state = {
state: "pending",
promise,
};
}
public get(): T {
if (this.state.state === "pending") {
throw this.state.promise;
} else if (this.state.state === "rejected") {
throw this.state.error;
} else {
return this.state.value;
}
}
}
Fetcher<T>
のコンストラクタはPromiseを返す関数を受け取ってすぐに呼び出します。ここで返されたPromiseの状態が監視され、this.state
に反映されます。
Fetcher<T>
が唯一もつメソッドget()
は、Promiseが解決済だった場合はその値を返します。まだ解決されていない場合はPromiseをthrow
します。一応、Promiseが失敗していた場合はエラーを投げる処理も入れています。
ポイントは、get
の返り値がT
型になっている点です。Promiseをthrow
して大域脱出するという荒技によって、get
を呼んだ側は非同期処理の途中かどうかを意識しなくても良くなります。何せ、T
型の値が返ってきているということはもうT
型の値がある、つまり非同期処理の結果があるということなのですから。つまり、get()
を呼んでT
型の値を得たコンポーネントは、あたかも非同期処理がすでに完了しているかのように処理を進めればよいのです。まだ完了していなかった場合はPromiseが投げられてしまいますが、その場合はReactが頑張って処理してくれます。
React Hooksが登場した時に「Algebraic Effectだ」なんて騒がれもしましたが、それと根本的な思想は同じです。すなわち、Reactが裏で頑張ることでシンプルなAPIを外向きに提供しているのです。
また、これだけ単純なラッパーでも、Promiseを投げるという点ですでにReactと癒着しています。しかし、前述の利点を得るためにはこれは欠かせません。これが、冒頭で触れた「Reactと非同期処理がより密結合になる」ということの意味です。
Fetcher
を使う例
Fetcher
を使うコンポーネントは、例えばこんな見た目になります。
type User = { id: string, name: string };
const UserList: FunctionComponent<{
usersFetcher: Fetcher<User[]>,
}> = ({ usersFetcher }) => {
const users: User[] = usersFetcher.get();
return (
<ul>
{users.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
};
UserList
コンポーネントは受け取ったFetcher<User[]>
のget
メソッドをいきなり呼び出してUser[]
を取得します。あとはそれを適当に表示するだけです。ここで、Fetcher<User[]>
は「User[]
型の結果を取得する非同期処理」そのものを表しています。get()
メソッドは、「その結果を取得する。まだ取得できない場合は取得できるまでサスペンドする」という意味になります。
このUserList
コンポーネントは例えば次のように使用できます(fetchUsers
が実際にUser[]
を取得する非同期処理を担当すると思ってください)。「Load Users」ボタンを押すとusersFetcher
にFetcher<User[]>
のインスタンスが入ってUserList
がレンダリングされます。なお、UserList
はサスペンドする可能性があるので、このようにSuspense
で囲んでフォールバックコンテンツ(中でサスペンドが発生したときに代わりにレンダリングされる内容)を指定しておく必要があります。
なお、Suspense
の中身でサスペンドが発生した場合はSuspense
の中身全体がフォールバックコンテンツに置きかわります。そのため、Suspense
をどこに置くかは、レンダリングが中断した時にどこまでフォールバックコンテンツになってほしいかによって決めることになります。Suspense
がネストしていた場合は一番内側のSuspense
が反応します。
const Container: FunctionComponent = () => {
const [usersFetcher, setUsersFetcher] = useState<
Fetcher<User[]> | undefined
>();
return (
<Suspense fallback={<p>Loading...</p>}>
<p>
<button
onClick={() => {
setUsersFetcher(new Fetcher(fetchUsers));
}}
>Load Users</button>
</p>
{usersFetcher ? <UserList usersFetcher={usersFetcher} /> : null}
</Suspense>
);
};
以上のようにして、実際に非同期処理を発生させて(fetchUsers
を呼び出して)以降の流れが全部実装できました。これを実際に動作させると、非同期処理の途中は「Loading...」と表示されて読み込まれたらUserList
の中身がレンダリングされます。
より具体的な流れとしては以下のことが発生しています。
-
Container
内でsetUsersFetcher
が呼び出されることでusersFetcher
ステートにFetcher
が入る。 -
Container
が再レンダリングされてUserList
がレンダリングされる。 -
UserList
がレンダリングされる(関数UserList
が呼び出される)最中に、get()
でPromiseがthrow
される(UserList
がサスペンドする)。 - サスペンドが発生したので、
Suspense
の中身として<p>Loading...</p>
がレンダリングされる。 - しばらくして
usersFetcher
が返したPromiseが解決される。 - ReactがPromiseの解決を検知し、以前サスペンドした
UserList
が再レンダリングされる。 - 今回は
get()
がPromiseを投げない(解決済のため)のでUserList
はサスペンドされずに描画される。
一応画面の動きを示しておくと、このようになります。
従来の方式との比較
一応、従来の方式(Concurrent Modeより前の書き方)との比較を行なっておきます。一例ですが、素朴に書くならこんな感じでしょう。
const Container: FunctionComponent = () => {
const [isLoading, setIsLoading] = useState(false);
const [users, setUsers] = useState<User[] | undefined>();
return (
<>
<p>
<button
onClick={() => {
setIsLoading(true);
fetchUsers().then(users => {
setIsLoading(false);
setUsers(users);
});
}}
>
Load Users
</button>
</p>
{isLoading ? (
<p>Loading...</p>
) : users ? (
<UserList users={users} />
) : null}
</>
);
};
ロード中・ロード完了という状態を表すためにisLoading
というステートが新設されました(TypeScript wayでReactを書くで説明したようにこれはベストなステートの表現ではありませんが、今回の本質にはあまり関わりません)。ボタンがクリックされたときは、「ローディング状態をにする→非同期処理を発火→終わったら結果をステートに反映」というステップを踏みます。
Concurrent Modeに比べるとやはり複雑化しており、とくにContainer
コンポーネントが非同期処理をハンドリングするためのロジックを内包するようになったのが気になります。これが非同期処理の辛い点であり、各種のライブラリが頑張って解決しようとしている点でもあります。
Concurrent Modeは、これに対して「非同期処理を表すオブジェクトそのものをステートに突っ込む」という斬新な解決策を提示しました。これは、非同期処理の扱いのつらい部分をサスペンドという機構に押し込むことで達成されています。
Concurrent Modeにおけるエラー処理
ここまでの例ではエラー処理を全く扱ってきませんでしたが、Concurrent Modeでは非同期処理に係るエラー処理も様変わりします。
というのも、非同期処理はPromiseで表されますが、Promiseというのは失敗(reject)する可能性があります。非同期処理におけるエラーはPromiseの失敗で表されます。では、throw
したPromiseが失敗したらどうなるのでしょうか。
答えは、Error Boundaryでキャッチされます。Error BoundaryはReact 16で導入された機能で、コンポーネントのレンダリング中にエラーが発生した場合にそれをキャッチしてエラー時のコンテンツをレンダリングできるものです。
従来は、非同期処理によるエラーはError Boundaryではキャッチされず、自前でハンドリングして必要なら自前でいい感じにUIに反映させるロジックを書く必要がありました。それは、非同期処理によって発生したエラーはレンダリング中に発生したエラーではないからです。
Concurrent ModeではPromiseをthrow
するという機構によって非同期処理がレンダリングによって組み込まれますから、非同期処理によって発生したエラーもレンダリング中に発生したエラーとして扱われるのは自然なことです。
Error Boundaryは宣言的なエラー処理機構なので、Concurrent Modeでは非同期処理に対しても宣言的なエラー処理が可能になったということです。たいへん嬉しいですね。
まとめ
この記事では、Concurrent Modeの基礎である「Promiseをthrow
する」という方針を実現するためにPromiseをステートに持って扱う方法について説明しました。これにより、より宣言的に非同期処理を扱えるようになると共に、エラー処理をError Boundaryの機構で統一的に扱えるようになりました。
次の記事: Concurrent Mode時代のReact設計論 (2) useTransitionを活用する
-
実際に上述のアプリで使われているバージョンではさらに
getOrUndefined
というメソッド(解決前だったらthrow
するのではなくundefined
を返す)があるのですが、これが本質的に必要なのかは悩んでいます。設計力の不足により必要になってしまっただけかもしれません。 ↩