読む:ReactのSuspense対応非同期処理を手書きするハンズオン

これ読む
3年前の記事ということは留意

最初にこれ紹介されてるので見る

ほー、Suspenseってそんなに大事なんだ
この記事では、いよいよ無視できなくなってきたReact 18に備えるにはどうすればいいのかについて端的にまとめます。
一言で言えば、答えはとにかくSuspenseを理解しろです。Suspenseのコンセプトさえを理解すれば、ストリーミングSSRやReact Server Componentsはその応用として理解することができ、これらの機能を使いこなせる状態に大きく近づきます。

なるほどなぁ、「責務の簡略化」か
このように、従来のやり方ではコンポーネント(TodoList)がデータの読み込みを担当し、ローディングかどうかという情報はそのコンポーネントが持つ状態でした。これにより、たとえローディング中であっても「TodoListをレンダリングできない」という事態は発生せず、ローディング中のUIを描画することはTodoListの責務でした。
それに対して、Suspenseを使う場合はTodoListの責務が簡略化されます。具体的には、ローディング中の処理(ついでにエラーの場合の処理)はTodoListの責務ではなくなります。

内部でPromiseがthrowされることでサスペンドするのか
具体的な機序としては、useQueryが内部でPromiseをthrowすることで行います。

なるほどなぁ、ただ投げ出してる訳では当然なく、内部でちゃんとリトライ機能もセットで保持していることは大事だな
「レンダリングを投げ出す」と表現しましたが、実際にはこのTodoListは裏で非同期通信を準備し、それが終わってローディング状態でなくなったら自分でリトライをかけてくれる真面目なコンポーネントです。一般に、サスペンドするコンポーネントはリトライがセットになっています。今回は5分で理解できる内容にするためにリトライ周りは省いています。

たしかに、それも別途考えたい
エラー処理については別途Error Boundaryを用意することで対処します。
tanstack routerのerrorComponent
プロパティ使えば解決かも(?)

いや、ErrorBoundaryというコンポーネントを独自実装するのがReactの慣習っぽい。初耳だ
関数コンポーネントではなくクラスコンポーネントが前提になっているのはへぇ~て感じ。古い感じがするというか、違和感があるな。まだ開発が追いついてないのか?よくわからん
ただそれを拡張した上で使えるライブラリ用意されてるからこれ使えば楽そうやな、今度使ってみよう
スター数も多い(7k)
前の個人開発、Suspense知らないがゆえに手続きで書いてたなぁ
従来はif (isLoading)のような手続き的なプログラムだったところがSuspenseコンポーネントという宣言的な方法になったところも注目に値します。

ふと湧いた疑問(Suspenseにしちゃうと困りそうなこと)
- disabled属性を切り替えられなくなってしまうのでは?
Suspense使ってisLoadingは削除した場合、buttonのdisabled={isLoading}
みたいなやつってどう実現すればいいんだろう、...できなくなるのでは?
- 記述量めっちゃ増えね?
ローディング中のコンポーネントも別で1から作っておかないといけない訳だよね?
そうなるとすごく巨大なコンポーネントをもう1つ作らないといけなくて、だるくないか?
Suspenseが無いからこそ1つのコンポーネント内でisLoadingで条件分岐さえすれば柔軟に特定の箇所にだけ描画変えたりできるから便利だったりするのでは...??

GPTに聞いたらなるほどぉぉになった
1に対してはstartTransition()
使えば解決(?)
(ただ、isPendingを結局使ってるやないかいという感じするし、transition使ったことないのでイメージ湧いてないので、今度ちゃんと検証したい)
2に対しては単純にそのコンポーネント内でもSuspense使ってdisabledになってるボタンをfallbackにしちゃえば良い。サスペンド終わったらdisabledではない通常のボタンが表示されるようにしとけば良いだけ

なるほど
Suspenseの面白いところは、複数コンポーネントをまとめて面倒見ることができるということです。次のようにすれば、ページの中の何かひとつでもサスペンドすればページ全体がスケルトンになります。ここでは3つのコンテンツがAppの中にありますが、それをひとつのSuspenseで囲んでいます。
const App: React.VFC = () => { return (<PageLayout> <Suspense fallback={<PageSkeleton />}> <MyProfile /> <TodoList /> <Comments /> </Suspense> </PageLayout>); };
当然個別に対応することも可
const App: React.VFC = () => { return (<PageLayout> <Suspense fallback={<MyProfileSkeleton />}> <MyProfile /> </Suspense> <Suspense fallback={<TodoSkeleton />}> <TodoList /> </Suspense> <Suspense fallback={<CommentsSkeleton />}> <Comments /> </Suspense> </PageLayout>); };

React 18のSSRストリーミングはSuspenseを前提にしています。
ほーなるほど、SSRのストリーミングでローディング完了時に返されるものは各SuspenseそのものつまりSuspense単位なのか
SSRのストリーミングは「初期状態(ローディング中でスケルトンとかが表示されている状態)を表すHTMLを最速で送り、その後データが揃ったらスケルトンを置き換えるHTML断片を追加で送る」という方式です。
この処理単位はSuspense単位です。つまり、初期状態というのはSuspenseのfallbackが表示されている状態であり、その部分がローディング完了した場合はSuspenseの中身丸ごとを置き換えるHTML断片が送られてきます。
このように、Suspenseは「非同期的なレンダリングが行われるひとまとまりの領域」を定義するという意味があります(実際にはSuspenseをネストさせることもできるのでもう少し複雑ですが)。
要するに、ストリーミングSSRを活用するためには非同期処理をSuspenseを用いて書く必要がある上に、どのようにSSRのストリーミングが進むかを制御するにはSuspenseを適切な位置に置く必要があるということです。細かくSuspenseを置いて回れば、それだけSSRのストリーミングも細かく進むことになります。
へー
また、React Server Componentsもその「レンダリングがサーバー側で(非同期に)行われる」という特徴から、(クライアント側から見ると)必然的にサスペンドの可能性があります。つまり、Server ComponentもSuspenseで囲む必要があるということです。

読み終わったので元記事に戻る

Chapter 02 Suspenseはどのような機能なのか

SuspenseのAPIは2つ
- ローディング中を宣言する部分
- 多分fallbackのこと
- コンポーネント本体

よくわからん
とはいえ、これらのAPIはローレベルです。つまり、便利さはさておき必要最低限のAPIのみが定義されており、その上に便利なAPIを構築するのはサードパーティに任せるというやり方です。だからこそ、Suspenseコンポーネントはさておき、実際のところ我々が書くアプリケーションコードでPromiseをthrowすることはほとんど無いでしょう。Promiseをthrowするなんて気持ち悪いという意見もよく見られますが、気持ち悪かったとしてもその点は普通ライブラリに隠蔽されるので、皆さんが心配する必要は無いということですね。
ほー、これでイメージ掴めそう
しかし、本書は生のSuspenseを体験するというコンセプトなので、次章からはどんどんPromiseをthrowしていきます。

Chapter 03 まずはSuspenseを試してみよう

これcloneするか
git clone https://github.com/uhyo/react-suspense-handson.git
npm i
npm run dev

おーなるほど
結果は、画面が真っ白になるです。コンソールには次のエラーが表示されているはずです。

なるほど
ちなみに、サスペンドが発生したらその部分だけでなく周りも巻き込んで表示できなくなる点に留意しましょう。「AlwaysSuspend部分だけ何も表示されない」のような挙動ではなく、「App全体が表示できない」という挙動になるのです。これはReactが提供する一貫性保証の一部であり、ある瞬間にレンダリングされたコンポーネントツリーが部分的に表示されてしまうようなことを防ぐためであると思われます。全部表示できるか、全部表示できないかのどちらかなのです。

ふむふむ
逆に言えば、Suspenseの中でサスペンドが発生した場合、そのSuspenseの中は全部巻き込まれてレンダリングできなくなります。例えば次のようにしても、「ここは表示される?」は表示されません。

Chapter 04 Suspenseの挙動を観察しよう

ほんとだ、なんでだ????
このようにすると、1秒ごとに「AlwaysSuspend is rendered」とコンソールに表示され続けることが確認できます。これが意味することは、「AlwaysSuspendの再レンダリングが1秒ごとに試みられている」ということです。これはなぜなのでしょうか。

なるほどぉぉ
その理由は、throwされたPromiseはサスペンドがいつ終了すると見込まれるかを示すものだからです。普通のコンポーネントは無限にローディングを続けず、いつかローディングが完了するものです。Promiseが解決されることで、ローディングの終了が表されるというのが意図されている実装です。
そして、ローディングが終了したらそれに合わせて画面を書き換えなければいけません。つまり、fallbackの内容を片付けてサスペンドしたコンポーネントの本来の内容を表示するという作業が必要なはずです。Reactは、これをサスペンドしたコンポーネントの再レンダリングという形で行います。普通、サスペンドしたコンポーネントから投げられたPromiseが成功裏に解決された場合、コンポーネントのレンダリングを再度試みれば、今度はサスペンドしないでレンダリングが成功することが期待されます。それにより、再レンダリングすればローディング完了後の画面になるというわけです。
ところが、このAlwaysSuspendは再レンダリングしたらまた新たなPromiseをthrowします。つまりこれは二度寝ですね。AlwaysSuspendは「あと1秒寝かせて……」を無限に繰り返すコンポーネントだったのです。これによりずっとサスペンド状態となり、だから画面がずっと「Loading...」のままだったのです。
なので「Reactの内部でsetTimeOutの第二引数(ms)を自動で読み取ってくれて、その時間が経ったら自動で再レンダーされるようになってる」ってこと?かも
つまりReactは「いつpromiseが解決されそうか」を自動判別するということだよな、かしこっ
しかしそこで疑問:
今回はsetTimeOutだったのでその第二引数を見れば容易に判別できるだろうけど、普通のfetchとかだったらどうなるんだ?いつpromise解決するか分からなくね?その場合いつ再レンダーすれば良いか、というのはどうやって決めるんだろう

gptに聞いてみたらなるほどぉになった
setTimeOutの第二引数を見てるとかそんな個別具体的なことしてなくて、ただ「Promiseを監視して、解決したら再レンダーするようになってる」ってだけ
なのでどんなpromiseの再レンダーにも対応できてる感じか、なるほど

なのでこの部分は、今回の例にのみ当てはまる説明であって、一般的な説明としては成り立たないということが言えそう
その理由は、throwされたPromiseはサスペンドがいつ終了すると見込まれるかを示すものだからです。
Promiseは「いつ終了すると見込まれるか」を表現することができている訳ではなく、「いつか終了するよ」を表すので、それが一般的な説明として適切だと思われる
まぁ恐らく自分が解釈を間違えたな
なので、一般的な説明として文章を変えるならば、
その理由は、throwされたPromiseはサスペンドがいつか終了するということを示すものであり、かつその終了をReactは検知できるからです。
って感じが良さそう

おー、SometimesSuspendおもろい
randomで50%でthrowするなら、再レンダーされてもそこでthrowされない場合ちゃんとreturnまで通過して解決する、てことか。なるほどぉ
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const SometimesSuspend: React.FC = () => {
if (Math.random() < 0.5) {
throw sleep(1000);
}
return <p>Hello, world!</p>;
};
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SometimesSuspend />
</Suspense>
);
}

練習問題: このアプリケーションにおいてLoading...と表示される秒数の期待値を求めよ.ただしsleep(1000)以外の要因による時間経過は無視してよい.
これ解きたいなー、数学感すごいぞ
まず期待値の公式はこれ
要は各場合の値とその発生確率を掛け合わせたものの総和
(最後に個数nで割るとかはしないので注意)

なのでLoadingと表示される秒数とその確率は以下の様になるはず
秒数 | 確率 |
---|---|
0 | 50% |
1 | 25% |
2 | 12.5% |
3 | 6.25% |
... | ... |
∞に近づく | 0に近づく |
なぜかというと...
- Loadingが0秒間表示される(=表示されない)
最初に表示されないとreturnが走ってpromiseは二度と投げられないつまり二度と再renderはされないので、単純に1回のみのギャンブル。つまり50%
- Loadingが1秒間表示される
最初に表示される、かつ2回目のレンダーで表示されない
最初に表示される確率は50%、2回目のレンダーで表示されない確率も50%、なので50%×50%=25%
- Loadingが2秒間表示される
最初と2回目で表示される、かつ3回目のレンダーで表示されない
0.5^3 = 12.5%
- Loadingが3秒間表示される
最初と2回目と3回目で表示される、かつ4回目のレンダーで表示されない
0.5^4 = 6.25%
ふーむ、s秒間表示される確率は
(だが今回求めたいのは確率ではなく期待値)

とりあえず愚直に式を立てるとこれ
なので
シグマで一般化
n=0は意味無いのでn=1からで良いかな、あと0.5は
無限級数か
無限等比級数ではないな、比率が一定ではないので(0.25が連続してたりするし)

- 無限級数が収束するための必要条件
以下無限級数が収束するなら、
以下が成り立つ
なので(*)の対偶命題として、上記のlimの結果が0でないなら、上記の無限級数は発散すると言える

ということでlimで計算してみる
したがって
と言える。
ということで、数列が0に収束してしまった...
期待値が0秒ってことはないはずだぞ....なぜだ

わからん。答え見る
解答
0 + 1⁄2 + 1⁄4 + … = 1 [秒]
む...??そもそもの確率の値をミスっていたっぽい...?
「1秒間表示される確率は50%、だから50%」というのが解答だと思われるが、自分は「1秒間表示される確率というのは、1秒間表示される、かつ2秒目は表示されないという確率、だから50%×50%で25%」と考えたのだが、違うのだろうか?
次の秒で表示されてはならないという条件も踏まえる必要があるのでは...?
確率わからん

とりあえず、解答の通り0 + 1⁄2 + 1⁄4 + …
となるなら、初項0は無視して良いので1⁄2 + 1⁄4 + …
と考えて、式は以下となる
無限級数を数列として変換し、その和を無限級数の和と呼ぶことができるので、数列に変換したい。
1/2倍ずつされていくので公比が1/2の等比数列と言える。初項
つまりこれは無限等比級数。
これに実際の値を代入すると以下になる
ということで、計算式の答えが1であるというのは確かなようだ
ただ、そもそもその計算式(確率の値)が自分とは違う...これはどっちが正しいのかはわからん...

ん、そういえば自分も0を考慮しちゃってるから、0を無視して考え直してみるか
んでgptいわくまたロピタルの定理を使って(?)以下のようになるらしい
うーん、わからん。もういいや、先に進むか

Chapter 05 非同期データ取得を実装してみよう

setData関数をそのまま渡してるのなぜだ?実行してないよなこれ
throw fetchData1().then(setData);
以下が正しいのではないだろか...
throw fetchData1().then(data => setData(data));

いや、これ両方まったく同じ動きをするっぽい!
thenは引数で受け取った関数に、解決した値を引数として自動的に渡して実行するのか!!!
知見だ

ん?保持できないっけ?できないと大変なことにならない?
ちなみに、useStateだけでなくuseRefを使ってもコンポーネント内にデータを保持することはできません。フック用の記憶領域はすべてレンダリングが完了しないと用意されないのです。
公式はレンダー間でデータは保持できると言っているぞ
レンダー間でデータを保持する state 変数
あー、なるほどレンダー間でデータ保持はできるが、各レンダーに着目して言うと、レンダーが終わってからじゃないと保持されないということか、理解

気になったことがありちょっと試す
ほー、Reactの基礎知識がまた1つ増えた
sleep(500).then(() => setData("boom!")); // 0.5秒後にsetState
throw fetchData1().then(setData); // 2秒後にsetState
これ、非同期処理が2つあってそれぞれstate更新するけど、
すべての非同期処理が完了してからsetStateがまとめて実行される、ということは起きない!
「state更新はバッチ処理として後でまとめて1回だけ起きる」というReactの常識は、同期処理にのみ通用する話のようだ!
非同期処理は各非同期処理が終了したらすぐstate更新してレンダーされてしまう。へぇ~~

理由を勝手に想像してみる:
多分Reactは同期処理を扱うコールスタックのみバッチ処理としてまとめてくれるだけで、非同期処理をまとめるタスクキューに関してはもう制御の範囲外で、タスクキューは1つずつ普通に実行されていき、setStateがそこにあったらそれも実行されていく、という流れなのかもしれない

いや、そもそもタスクキューの中身はsetTimeOutのms経過後に結局コールスタックに流されるから、その時点ではコールスタックには1つしかsetStateないよね、だから即時実行しちゃうね、て感じか!
(タスクキューからコールスタックに渡されているのは前見た)
(ちなms経過後にワーカースレッドからタスクキューに非同期処理の中身が投げられる)
あくまで仮説にすぎないが、とりあえず腑に落ちた

??
一度再レンダリングされた時点で、Promise解決によるリトライのスケジュールはキャンセルされます。
リトライというのは恐らく「promise解決後自動で実行される再レンダー」のこと
リトライ自体は行われるのでは?「1度レンダーされた時点で、それ以降のpromise中のsuspenseを監視する機能が失われる」とかなら理解できるが...
試してみても、やっぱり再レンダー(リトライ)自体は行われてるしなぁ

なるほど
つまり、すでにマウント済みのコンポーネントに関しては、Promise解決による再レンダリングはあくまでサスペンド解除の手段のひとつであり、他の手段(ステート更新)により再レンダリングを引き起こしてサスペンドを解除させることもできるのです。

んん?なぜsetDataの方が早い?むずいな
この場合も、実は「Promise解決による再レンダリング」より前に「setDataによる再レンダリング」が起こっています。fetchData1().then(setData)の返り値であるPromise(throwされたPromise)が解決されるよりも、setDataが呼び出される方が先だからです。

んーと、まず
fetchData1().then(setData)
の返り値であるPromise(throwされたPromise)が解決される
は、実質「thenの中に入る」ことと同義だと思うので、それはいつかというとfetchDataがresolveされた時なので、2秒後。厳密にはfetchData1の処理が終わったら。
...あ!違うか。それはfetchData1()
の返り値の解決であって、thenも含めて全部の返り値の解決ではないな
あーわかったかも
単純にこういうことだ↓
async function f() {
const a = await fetchData1().then(data => {
console.log('thenに入った'); // これが先に出力される
return data;
});
console.log('await終了。aは->', a); // これが後に出力される
}
export default function App() {
f();
return (
<h1>hello</h1>
)
}
thenも含めたすべての結果(返り値)が解決されるのは、awaitを通過したことと同義であって、そのタイミングは当然then内の処理より後だよねーという感じ
(thenやらなんやらがすべて終わったらやっとawait終了と言える、みたいなのがpromiseの基本なので)
今回はthen内にsetData()
してるから、当然setData
の方が早く実行されるということだな
理解

よくわからん
これでも期待通りに動くことは動くのですが、「投げたPromiseが解決されたら再レンダリングされる」という分かりやすいモデルから外れて「とにかくサスペンドの意思を伝えるためにPromiseを投げる」という挙動になっています。
2回目のpromiseに関しては、投げてもサスペンドされてないしなぁ...
なので、
「promise投げる -> suspendされる -> 解決後のレンダー(リトライ)」が1度起こるとそれ以降はpromise投げて解決されようがリトライされない、サスペンドキャンセル的なことが起きる
という事実だけとりあえず覚えておこう
実際に試してみたけど、他のイベントハンドラ作ってpromise投げてもやっぱりサスペンドは起きずに画面だけ更新された。へぇ~知見だ

ほー
ということで、次章ではよりよいサスペンド方法を考察していきます。結論を先取りしてしまうと、何らかの手段でコンポーネントの外にデータを持つことが必要になってきます。

これは、
ところで、コンポーネントのレンダリングがサスペンドした場合、レンダリングを試みたという事実が歴史から抹消されるのでした。
ここのこと
つまり、そもそもコンポーネントのレンダリングが成功していないので、ステートの記憶領域が用意されていないのです。つまり、コンポーネントがサスペンドした場合には、(そのコンポーネントから投げられたPromiseを除いては)そのコンポーネントのレンダリングを試みたという記録が歴史から抹消されます。よく「Reactコンポーネントのレンダリング中に副作用を起こしてはいけない」と言われますが、その実際的な理由の一端がこれです。レンダリングは無かったことにされたのに副作用だけ残ってしまう恐れがあるので、やってはいけないのです。
副作用だけ残ってしまう場合というのは、多分setStateで更新(=副作用)したあとにpromise投げてsuspendした時とかだろうな

mount時だけでなくupdate時も同じだよ、という話
これはすでにレンダリングされたコンポーネントが再レンダリング時にサスペンドした場合も例外ではありません。この場合、その再レンダリング中にコンポーネントの記憶領域に書き込まれたものがロールバックされます。
ロールバック = トランザクション処理のキャンセル
つまり歴史の抹消をロールバックと言っている

なるほどぉーほんまや、2回ログに出る
意味のないuseMemoを追加してみました。loadingがtrueの際にログを出力するようになっています。この状態でloadボタンを押すと、「loading is true」というログは2回出力されます。ボタンを押した直後に1回、1秒後のデータ取得完了時に1回です。
これは、ボタンを押した直後にloadingがtrueの状態でレンダリングが行われてuseMemoの関数が呼び出されたものの、そのレンダリングはサスペンドしたため、メモ化内容(useMemoの結果)が捨てられたことを意味しています。そのため、1秒後の再レンダリングでは再度useMemoの関数が呼び出されたのです。console.logを用いたデバッグはよく行われますが、サスペンドが絡んだコンポーネントをデバッグする際はこういったことにも気をつける必要があります。
(strict modeで3回出てるだけで実質2回)
ということはサスペンドを先に実行するように順序を変えたら、console.logは1回しか実行されないはず。useMemoの前にconsole.log置いてみる
おー!やはり1回だけだった。なるほど、理解
(strict modeで2回出てるだけで実質1回)

んで、useMemoだけでなくuseStateも確かにロールバックされていることを確認できた。
(falseにしようとするsetStateがキャンセルされ続けるので、ずっとtrue)
const DataLoader: React.VFC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<string | null>(null);
if (loading) {
console.log('fire');
if (loading) {
console.log('loadingはtrue');
} else {
console.log('loadingはfalse');
}
setLoading(false); // これが毎回キャンセルされるので実質機能しない
throw fetchData1();
}
if (loading && data === null) {
throw fetchData1().then(setData);
}
return (
<div>
<div>Data is {data}</div>
<button className="border p-1" onClick={() => setLoading(true)}>
load
</button>
</div>
);
};

Chapter 06 コンポーネントの外部にデータを持とう

モジュール(ファイル)のトップレベルで普通にグローバル変数作ってそれをミュータブルに更新するのかなるほど

「冪等な副作用」という比喩?がよくわからない
コンポーネントのレンダリングの最中に副作用を起こすべきではないのに結局fetchData1()しているじゃんという疑問もあるでしょう。それは正しい指摘ですが、一度データを取得できてしまえば再取得が起きることはなく、ある意味冪等な副作用(?)となっているのでそこまで大きな問題ではありません。
「冪等」ではなく「レンダー中に使っても問題ない」ならわかるけど、冪等ではないよなぁ
冪等 = 何度やっても結果が同じこと
副作用自体のロジックは乱数生成なので当然冪等ではないはず...

副作用を実行するスコープではif文で条件分岐しており、結果的に2回目レンダー以降は副作用が実行されないという意味で1回目と2回目以降でコンポーネント(レンダー)の出力が冪等ではないのは別に問題ないけれども。コンポーネントは毎回出力変わるので冪等ではなくて良く、純粋であれば良いみたいな感じだと思うので

だが当然そんなことはわかっているはずで、それを踏まえて比ゆ的に「冪等な副作用」と言っていると思う。
どういう比喩だろう?
あーわかったかもしれない
1回目は無視するとして、2回目以降はずっと同じ出力(ifに通過しないので副作用は実行されない)だよね、だからまぁ雑に冪等と言っちゃおうよ、みたいな感じかな

んーでもその場合、やはり副作用自体は冪等ではないので、
「冪等なコンポーネント」と呼ぶ方が適切ではないだろうか

ふむ
ある意味冪等な副作用(?)となっているのでそこまで大きな問題ではありません。むしろ、Suspense設計対応の肝は、キャッシュなどいろいろな工夫によってデータ取得をReact的な意味で“副作用”ではなくしていくことにあります。上の単純な実装ではタイミングによってはfetchData1()が複数回実行されてしまうという問題がありますが、工夫すればどうにかなる問題です。
副作用ではなくしていくってどういうことなんだろう?イメージできない
とりあえず読み進めていく

カスタムフックでまとめるのもアリか、なるほど
(中で1個もフック使ってないけどそれもアリではあるのか)
function useData1(): string {
if (data === undefined) {
throw fetchData1().then((d) => (data = d));
}
return data;
}

ほぇーなるほど (nullというかundefinedでは?)
双方ともとてもシンプルになりましたね。これがフックの威力です。特に注目すべき点としては、useData1の返り値の型がstringとなっていて、nullがインターフェースから隠蔽されている点です。詳しくは以下の記事で解説していますが、throwの活用によってインターフェースをシンプルにできるのがSuspenseの面白い点です。
「並行モード」と「promiseを投げる」は同じように考えて良さそう
Concurrent Modeの代名詞として多くのReactユーザーに知られているのはPromiseをthrowするというAPIデザインです。

ほー
また、DataLoaderの側も、サスペンドする可能性があることが見えにくくなっているものの、ローディング中という状態を意識しないコードにすることができました。サスペンドの可能性が見えにくいという問題点は、コンポーネントはサスペンドするのが普通というように我々のマインドセットが変わっていくことで解消されていくのではないかというのが個人的な予想です。
「コンポーネントはサスペンドするのが普通」かぁ、おもしろい

Mapオブジェクトのキーをキャッシュキーとして使うのか、なるほどぉ
const dataMap: Map<string, string> = new Map();
function useData1(cacheKey: string): string {
const cachedData = dataMap.get(cacheKey);
if (cachedData === undefined) {
throw fetchData1().then((d) => dataMap.set(cacheKey, d));
}
return cachedData;
}
使う側
const DataLoader1: React.VFC = () => {
const data = useData1("DataLoader1");
return (
<div>
<div>Data is {data}</div>
</div>
);
};
const DataLoader2: React.VFC = () => {
const data = useData1("DataLoader2");
return (
<div>
<div>Data is {data}</div>
</div>
);
};

ふむふむ
こうすると2つの別々のデータが表示されるようになりましたね。キャッシュキーというグローバル管理な値が残っているところが気になりますが、コンポーネントごとに異なるデータを取得できるようになり、進歩しています。

ほー、危険なasを使うことにはなるけど、カスタムフックを汎用化できるのか
const dataMap: Map<string, unknown> = new Map();
export function useData<T>(cacheKey: string, fetch: () => Promise<T>): T {
const cachedData = dataMap.get(cacheKey) as T | undefined;
if (cachedData === undefined) {
throw fetch().then((d) => dataMap.set(cacheKey, d));
}
return cachedData;
}
使う側
const DataLoader1: React.VFC = () => {
const data = useData("DataLoader1", fetchData1);
return (
<div>
<div>Data is {data}</div>
</div>
);
};
const DataLoader2: React.VFC = () => {
const data = useData("DataLoader2", fetchData1);
return (
<div>
<div>Data is {data}</div>
</div>
);
};

へぇ!そうなんだ
おお、useDataのインターフェースが、非同期データフェッチングライブラリの一つとして知られるReact Queryが提供するuseQueryフックとだいたい同じですね。React QueryのSuspense対応を説明するページではSuspense対応モードではisLoadingやerrorといったものは必要ないことも説明されていますから、Suspense対応の前提ではReact Queryと今回作ったフックはインターフェースが同じと言えます。
const { isLoading, error, data } = useQuery('repoData', () => fetch('https://api.github.com/repos/tannerlinsley/react-query').then( res => res.json() ))
じゃあTanStackQuery(旧ReactQuery)もこういう感じでキャッシュを使っているのかな?おもしろい
よし
つまり、以上で原始的なReact Queryを実装することができたということです。おめでとうございます。

Chapter 07 Render-as-you-fetchパターンの実装

たしかに
前章ではSuspenseに対応した非同期データ取得を実装できました。実はあそこで実装されたのは、React Queryのドキュメントの言葉を借りればFetch-on-renderパターンです。つまり、useDataを使用するコンポーネントがレンダリングされた時点でデータの取得が始まるということです。
何が違うんだろう?同じに聞こえるが
一方で、当初Suspense for data fetchingが発表された時に喧伝されていたSuspenseの利点として、Render-as-you-fetchパターンが可能であるという点がありました。そこで、次はこちらのパターンの実装にもチャレンジしてみましょう。

ほー、ということはFetch-on-renderとは順番が逆ってことなのかな
Render-as-you-fetchパターンとは、色々なデータが取得されるにつれて、その部分を表示するコンポーネントがレンダリングされていくという挙動のことです。
Fetch-on-render:レンダーされるとfetchが始まる
Render-as-you-fetch:fetchされるとレンダーが始まる

関係ないけど、awaitって内部でthenを使用しているのか、たしかにそうか(ほんとに関係ない)
内部でthenを利用しているawait

ふむ、promise->thenのように非同期ではなく、同期的にローディング中のデータを受け取りたいのか
これを実現するには、レンダリングを担当するコンポーネントは「ローディング中のデータを受け取る」という機能が必要です。つまり、「ローディング中のデータ」という概念を表す値が必要になってきます。お察しのとおり、JavaScriptでこれを担当するのはPromiseオブジェクトなのですが、実は今回はPromiseでは機能が不足しています。というのも、解決されたPromiseから中身を取得するにはthen(または内部でthenを利用しているawait)が必要であり、必ず非同期的に値を取得することになるのです。一方で、コンポーネントのレンダリングは同期関数なので、同期的に取得された値を取り出せることが必要です。
あー言いたいことわかったかも
thenだとfetchが100%完了してからじゃないと機能しないけど、そうではなくfetch実行中もずーっと経過を確認していたい、何ならその途中段階のデータも常に同期的に取得したい、という感じか!
たしかにRender-as-you-fetchではそれが必要だという論理も理解できる!なるほど

ほぇーむずかしいけどなるほど
type LoadableState<T> =
| {
status: "pending";
promise: Promise<T>;
}
| {
status: "fulfilled";
data: T;
}
| {
status: "rejected";
error: unknown;
};
export class Loadable<T> {
#state: LoadableState<T>;
constructor(promise: Promise<T>) {
this.#state = {
status: "pending",
promise: promise.then(
(data) => {
this.#state = {
status: "fulfilled",
data,
};
return data;
},
(error) => {
this.#state = {
status: "rejected",
error,
};
throw error;
}
),
};
}
getOrThrow(): T {
switch (this.#state.status) {
case "pending":
throw this.#state.promise;
case "fulfilled":
return this.#state.data;
case "rejected":
throw this.#state.error;
}
}
}
このクラスはnew Loadable(なんらかのPromise)のように使います。Loadableの内部でステート(#state)が管理され、Promiseが解決(成功または失敗)するとそのことを記録します。Promise本体とは別に管理することで、必要な場合に同期的に内容を取得できるようにします。その際に使うのがgetOrThrowメソッドで、Suspenseでの利用を見越した実装になっています。

ほー、実際にどうやって同期的に使うのか気になる
getOrThrowメソッドはラップされたPromiseが成功裏に解決済の場合はその値を返します。それ以外の場合、Promiseが失敗した場合はそのエラーを投げます。そして、まだ解決していない場合はPromiseを投げます。
そして、このようなオブジェクトを「ローディング中のデータ」としてコンポーネント間でやり取りすることでrender-as-you-fetchパターンが実現できます。

使う側はこれか、なるほど
const DataLoader: React.VFC<{
data: Loadable<string>;
}> = ({ data }) => {
const value = data.getOrThrow();
return (
<div>
<div>Data is {value}</div>
</div>
);
};
export default function App() {
const [data1] = useState(() => new Loadable(fetchData1()));
const [data2] = useState(() => new Loadable(fetchData1()));
const [data3] = useState(() => new Loadable(fetchData1()));
return (
<div className="text-center">
<h1 className="text-2xl">React App!</h1>
<Suspense fallback={<p>Loading...</p>}>
<DataLoader data={data1} />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<DataLoader data={data2} />
</Suspense>
<Suspense fallback={<p>Loading...</p>}>
<DataLoader data={data3} />
</Suspense>
</div>
);
}
うわーなんとなくわかったような気がするけどすごく難しいな...
頭良いことしてるなぁ、これで同期的に扱えるし型安全だし抽象化に成功しているのかぁ、すごいなぁ、というなんとなくのイメージ

うおーむずい、あまりイメージできない。だが、なるほど。
このパターンは他にも、1つのデータを複数箇所のDataLoaderに渡したりといった応用が可能です。Suspense対応のデータを、コンポーネント間でprops(あるいはコンテキストなど)を通じて受け渡すという従来のモデルに乗せて取り扱える点が魅力的ですね。ただし、この方法では副作用が不必要に何回も起こらないようにキャッシュするなどの工夫は別途必要になってきます。
副作用のキャッシュということは、fetch〇〇関数をメモ化するということかな

useTransitionは知ってるがSuspense Cacheは初耳だ
この本はSuspenseの基本を触っただけで、他にもSuspenseには奥深い機能が備わっています。具体的にはuseTransitionやSuspense Cacheなどです。これらについては、次の機会にご紹介するとしましょう

んじゃあRender-as-you-fetchをまとめると...
- fetch完了につれて該当コンポーネントがレンダーされていく
- とはいえ、fetchが一番最初というわけではない。当然レンダーされないとfetchなんて起きないので最初にレンダーが起きないといけないのは必然
- (まぁprefetchとかはレンダー前にfetchするけど)
- なのでfetch->renderというよりrender->fetch->render的なイメージかな
- とはいえ、fetchが一番最初というわけではない。当然レンダーされないとfetchなんて起きないので最初にレンダーが起きないといけないのは必然
-
useData(->fetch)を使用する関数とそれを表示するコンポーネントが別になる
- 今回ではApp()がfetch開始トリガーのコンポーネントで、DataLoaderがそれを表示するコンポーネント
- DataLoaderは内部でfetchせず、あくまで同期的に状態を取得できる関数を実行するだけ
- Fetch-on-renderはそうではなく、使用するコンポーネントがレンダーされた時点でfetchが開始する、つまりDataLoader内でuseData(->fetch)も行う
なるほどなぁ。太字にした部分が大事な気がする
んで「同期的に状態を取得できる関数」という発想がミソというか、責任分離できてるし同期も実現する賢いやり方なんだろうなぁという感じ
理解

続編があるらしい。いつか見よう
ただし、この本で触れたのはSuspenseの基本的な部分です。まだまだ奥が深い部分がありますので、それはまたの機会にご紹介します。
→続編ができました。さらに深く知りたい方はこちらも合わせてお読みください。