ReactのConcurrent Modeが最初に発表されたのはもう1年近くも前のことです(記事執筆時点1)。Concurrent Modeはたいへん奥深い機能で正式版がたいへん待ち遠しいですが、Concurrent Modeの代名詞として多くのReactユーザーに知られているのはPromiseをthrowするというAPIデザインです。Concurrent Modeでは、コンポーネントがレンダリング時にPromiseをthrowすることで、レンダリングをサスペンドした(Promiseが解決されるまでレンダリングできない)ことを表します。
Concurrent Modeに関しては筆者の既存記事Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理などをご参照いただきたいのですが、ここではPromiseをthrowするということ自体に焦点を当てます。
Proimseをthrowするというのは斬新な行為で、多くのReactユーザーに驚きをもって迎えられました。驚きだけではなく、気持ち悪いとか分かりにくいといった反発も見られました。そこで、この記事ではPromiseをthrowするというAPIデザインがいかに天才的かを解説します。キーワードは大域脱出と型、そして副作用です。
Promiseのラッパー: Loadable
いきなり核心に迫りますが、次のように定義されるPromiseのラッパーを考えてみてください。Recoilに倣ってこれをLoadable
と呼びましょう。
type LoadableState<T> = {
type: "pending";
promise: Promise<T>;
} | {
type: "fulfilled";
result: T;
} | {
type: "rejected";
error: unknown;
}
class Loadable<T> {
#state: LoadableState<T>;
constructor(promise: Promise<T>) {
const p = promise.then((result) => {
this.#state = {
type: "fulfilled",
result,
};
return result;
}, (error) => {
this.#state = {
type: "rejected";
error,
};
throw error;
})
this.#state = {
type: "pending",
promise: p,
};
}
get(): T {
switch (this.#state.type) {
case "pending": {
throw this.#state.promise;
}
case "fulfilled": {
return this.#state.result;
}
case "rejected": {
throw this.#state.error;
}
}
}
}
このラッパーはnew Loadable(new Promise(...))
のように、Promiseをラップする形で使います。Loadableは内部の#state
に今Promiseがどの状態なのか("pending"
, "fulfill"
, "rejected"
のいずれか)を保持しています。"pending"
(まだPromiseが解決していないとき)はPromiseを保持しており、"fulfill"
(解決済)のときは解決された値を保持しており、"rejected"
(失敗済)の時はエラーオブジェクトを保持しています。
そして、特徴的なのはget
メソッドです。この関数は、Promiseが解決済のときのみその値を返します。Promiseが未解決の場合はPromiseをthrowします。一応、Promiseが失敗していた場合はエラーをthrowするようになっています。
大域脱出と返り値の型
get
メソッドの返り値の型がT
型であるのはとても注目に値します。LoadableはPromiseという非同期処理を取り扱うクラスのはずなのに、get
の返り値の型からはそのことが消えています。これは、await loadable.get()
などとせず、単にloadable.get()
とすることで目的の値が取得できるということです。
ただし、お分かりの通り、get
がちゃんと値を返すのはPromiseが解決済である時だけです。だからこそ、T
型という返り値の型が実現しているわけですが。
そして、そのトリックは言うまでもなく、T
型の値を返せない時はthrowしてしまうというget
の実装によるものです。実際にReactアプリケーションの中でLoadableを使用した場合、throwされたものはReactが裏で面倒を見ることになります。
言い方を変えれば、Loadableは非同期処理の扱いをReactに丸投げ(throwだけに)することによってインターフェースから非同期処理を隠蔽しているのです。
この「丸投げ」が可能なのは、throwが大域脱出(実行をその場で中断して外側に制御を移すこと)を伴う構文だからです。JavaScriptの構文の中でも関数から脱出する大域脱出が可能なのはthrowだけです。関数の外側へ大域脱出するということは、関数から返り値を返すのを放棄するということでもあります。関数から戻り値を放棄することによって、関数の返り値の型(T
)に影響を与えずに関数の実行を中断することができます。これがthrowの力であり、このためにthrowが使われるのは必然であると言えます。
また、このような実装が可能になる裏には、以上の事情をきちんと処理・推論することができるTypeScriptの尽力があってこそです。
簡単な例で理解する
上記のLoadableの例はいきなり核心に迫るよい例ですが、いまいちピンとこないという方もいるかもしれません。もっと簡単な例でも大域脱出の力を見ておきましょう。
function getDataOrUndefined(): string | undefined {
// ...
}
このようなgetDataOrUndefined
があったとしましょう。その名の通りデータを取得する関数ですが、データが無いかもしれません。そのときは結果がundefined
となります。
しかし、ここで「いやundefined
とか困るから絶対にstring
を返して」というオーダーがあったとしましょう。
明らかに無理難題ですが、throw
なら解決してくれます。そう、なかった場合の処理は外に丸投げしてしまえばいいのです。
function getData(): string {
const maybeData = getDataOrUndefined();
if (maybeData === undefined) {
throw new Error("あとは任せた!!!!!!!");
}
// ここでは maybeData は string型
return maybeData;
}
こうすれば必ずstring
を返す関数であるgetData
の完成です(例外が投げられる可能性が生じましたが)。
一見ふざけているように見えますが、エラーハンドリングさえできるのであればこれもまともな設計になり得ます。むしろ、こうやって外側にエラー処理を任せてどこかにエラーハンドリングの処理をまとめる方が伝統的なエラーハンドリングの姿ではないでしょうか。
このように大域脱出は、外側でのハンドリングの必要性と引き換えに、型をシンプルにする効果を持っているのです。Reactの場合は、そのハンドリングを全部Reactがやってくれるというわけです。
大域脱出と副作用
この記事では、throwを用いた大域脱出によって非同期処理をインターフェースから隠蔽するというAPIデザインの妙について説明しました。
大域脱出は、関数型的な文脈では副作用の一つであると考えられています。
また、ReactはReact Hooksの登場以降このような複雑性の隠蔽を特徴としてきています。React Hooksにおいても、「状態を持つ」という副作用を、それをうまく隠蔽するフックというAPIを通じて利用できるようになっています。今回の「Promiseをthrowする」というのは、実際にthrowという副作用を隠蔽するのはReact本体ではなく(先述のLoadableのような)Suspenseに対応したステート管理ライブラリやデータフェッチングライブラリになるわけですが、いずれにせよユーザーからは隠蔽される(ユーザーからはget(): T
のような副作用のないAPIが見える)という点は同じです。
その意味で、ReactのConcurrent Modeで導入される「Promiseをthrowする」というのは突飛な変化ではなく、むしろReact Hooksの流れを汲んだ正統進化であるということができるでしょう。
まとめ
まとめると、ReactのSuspenseにおいては、throwによる大域脱出と型推論の天才的かつ芸術的なコラボレーションによって非同期処理からその非同期性を隠蔽することができているのです。Suspense(throwによりReactコンポーネントのレンダリングがサスペンドするやつ)は主にデータの取得のために使われることが想定されています。データとは、LoadableのT
型に相当するものです。Loadableのget
を使えば、返り値がT
なのですから非同期処理であることをコンポーネントの表面から隠蔽しながらデータ取得ができるというわけです。
また、この新しい機能は副作用の隠蔽というテーマにおいてReact Hooksの流れを維持した進化形態となっています。
追記:コメント返し
この記事を公開してからすぐに色々な反響がありました。そこで、いくつかのコメントに反応したいと思います。コメント返しは記事の至らないところを最短距離で補える素晴らしいやり方ですが、みなさまのコメントがあってこそです。コメントをくださった皆様ありがとうございます。
ここでは反対意見ばかり取り上げていますが、お褒めの言葉もいただいています。重ねてお礼申し上げます。
- 型チェックをランタイムのチェックに変換していてむしろ型の守備範囲が狭くなってしまっているのでは?
これはその通りですね。throw
は本来ハンドリングすべき情報(undefined
の可能性とか)を型から消すことができますが、逆に言えば型から消えてしまったものはランタイムの機構(try-catchとか)でうまく補ってあげる必要があります。TypeScriptの登場以降、主にこのことが理由でthrow
が敬遠されるようになってきた歴史があります。
筆者の考え方としては「ライブラリ(React)のレベルで危険性が隠蔽されていればそれでいい」と考えています。これは筆者の以前のトーク「安全性の極北から見るTypeScript」などで説明した考え方ですが、よいインターフェースのためにさまざまな単位(文、関数、モジュール、そしてライブラリ単位)で危険性を受け入れ、隠蔽するのも価値があることです。
- Promiseをthrowする理由が分からなかった。
- Promiseをthrowするメリットを説明していない。
はい、この記事ではPromiseの部分は触れずに説明しました。どちらかというとthrowの部分が本質的に重要だからです。
Promiseを投げるのは、それをキャッチしたReactに「このコンポーネントはいつレンダリングの準備ができるのか」を伝えるためです。投げられたPromiseが解決されたら、それを投げたコンポーネントのレンダリングをリトライするという仕組みになっています。
- 「大域脱出と型推論の天才的かつ芸術的なコラボレーション」の型推論要素どこ?
TypeScriptがthrow
が含まれるロジックをうまく解釈し、get()
の返り値の型がT
となったりgetData()
の返り値の型がstring
となったりするのはTypeScriptが頑張ってくれたおかげです。具体的には、フロー解析と呼ばれる処理を行わないとこの推論はできません。
また、どちらかというと大域脱出と「型システムそのもの」のコラボレーションと言ってもいいかもしれません。
- 素人が仕事のコードで真似すべきではない。
- 多くの開発者がthrow Promiseを真似すると地獄になる。
そうですね、仕事でReactを再実装するのは避けたほうが良いです。ただ、この記事はPromiseにはそこまで注目しておらずthrowに焦点を絞っていましたから、throwを使うと即地獄が生まれるかどうかは意見が割れそうですね。尤も、近年の関数型チックな風潮の中ではthrowを全部禁止したくなる気持ちも分かります。自分のところの設計と相談しましょう。
- 他のライブラリもPromiseをthrowするようになったら混ざってしまう。
これは説得力がある懸念だと思いました。とはいえ、Reactの場合はコンポーネントは完全にReactに制御されたコールスタックの中で実行されますから、Reactと無関係ないライブラリがPromiseをthrowしても(無責任にPromiseを投げっぱなしにしない限りは)取り違えが発生することはまず無いでしょう。Reactのコンポーネントツリーの中に(React本体のSuspenseとは無関係に)Promiseを投げるサードパーティーライブラリも考えられない訳ではありませんが、それはReactに乗っかっている以上、サードパーティーライブラリ側が配慮すべきだと思います。その意味では、(知られる限りでは)最初にPromiseの“意味”をReact本体側で予約したのは上手くやったなと思います。
- 天才的かつ芸術的という語彙で本質をぼかしている。
- 天才とは程遠いただ愚かなだけのプログラミングスタイルである。
自分は天才的かつ芸術的だと思ったのでそう書きましたが、万人に賛同されるものではなかったかもしれません。いずれにせよ、天才的という言葉がなくてもこの記事で伝えたかったことは伝わると考えています。
- エラー以外のものをthrowで投げるのが気持ち悪い。
- エラー以外のものをthrowで投げるのはthrowの本来の意図に反する。
- こんなことにthrowを使うのはバッドノウハウ・アンチパターンである。
筆者の感覚では、気持ち悪いという感情的な問題よりはインターフェースが整うといった実利的なメリットの方が優先されると思っています。エラー以外を投げるのはthrow
の本来の意図に反するという点については、そうはいっても言語仕様上許されるのだから使い倒せばいいじゃんと思っています。何と言っても、関数境界を超える大域脱出が可能なのはthrow
だけなのですから。
とはいえ、考え方の問題ですから筆者が頭ごなしに否定することもできません。ぜひ自分の考え方を貫きましょう。
- gotoの再発明。
- モダンgoto。
確かに大域脱出ができるという点でgotoとthrowは似ていますね。インターフェースを簡潔にするという目的はTypeScriptにgotoがあればそれで解決できたかもしれません。ただ、throwは行き先を指定しないという点やcomposableであるという点でgotoとは異なり、そうでなければReactにこのように使われることは無かったと思います。
-
2020年9月 ↩