この記事は「Concurrent Mode時代のReact設計論」シリーズの3番目の記事です。
シリーズ一覧
- 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) まとめ(仮)
Suspense
やuseTransition
が何を解決するか
前回までは、Promiseをthrow
してSuspense
がキャッチするというConcurrent Modeの特徴、そして「非同期処理そのもの(Promise)をステートで管理する」という設計指針において欠かせない部品であるuseTransition
について見てきました。
useTransition
は「2つのステートを同時に扱う」という斬新な概念を導入しました。そうまでしてConcurrent Modeが「Promiseをステートで管理する」という設計を貫く理由はおもに3つあると考えられます。まず非同期処理にまつわるロジックを分割するため、そして非同期処理をより宣言的に扱うためです。最後に、これは公式ドキュメントでも強調されていることですが、render-as-you-fetchパターンの実現です。ここからは、この3つを達成するためにどのような設計が必要かについて議論します。
前回出てきた「画面Aから画面Bに遷移するためにデータを読み込んでいる間は、画面Aに留まって読み込み中の表示にしたい」というシチュエーションについて再考してみます。従来(Concurrent Modeより前)の考え方では、画面Bへの遷移は2つの段階に分割できます。すなわち、「画面B用のデータをロード中の段階」と「ロードが終わって画面Bをレンダリングする段階」です。
この指針に基づいて作った従来型の実装をまず考えてみます。
非同期処理を含む画面遷移の従来型実装
画面Aと画面Bという2つの画面が存在しますから、今どちらの画面かといったステートを司る存在が必須です。とりあえずこれをRoot
と呼びましょう。画面Bは前回から例に出てきているUser[]
型のデータを表示するとすると、Root
はこんな感じで定義できます。
type AppState =
| {
page: "A";
}
| {
page: "B";
users: User[];
};
export const Root: FunctionComponent = () => {
const [state, setState] = useState<AppState>({
page: "A"
});
const goToPageB = () => {
fetchUsers().then(users => {
setState({
page: "B",
users
});
});
};
if (state.page === "A") {
return <PageA goToPageB={goToPageB} />;
} else {
return <PageB users={state.users} />;
}
};
Root
コンポーネントの最後に注目すると、今画面AにいるときはPageA
をレンダリングし、画面BにいるときはPageB
をレンダリングするようになっています。画面Aは画面Bに行くボタンを持っている想定なのでgoToPageB
という関数をpropsで受け取ります。一方の画面BはUser[]
を表示するのでUser[]
をpropsで受け取ります。goToPageB
が呼ばれた場合、fetchUsers()
が完了するまでは現在の画面にとどまり、完了し次第setState
により画面Bを表示という実装です。
PageA
の実装はこんな感じになりますね。
const PageA: FunctionComponent<{
goToPageB: () => void;
}> = ({ goToPageB }) => {
const [isLoading, setIsLoading] = useState(false);
return (
<p>
<button
disabled={isLoading}
onClick={() => {
setIsLoading(true);
goToPageB();
}}
>
{isLoading ? "Loading..." : "Go to PageB"}
</button>
</p>
);
};
画面Aは「画面B用のデータを読み込み中はローディング中の表示にする」というロジックのためにisLoading
ステートを持っています。それ以外は特筆すべき点はありませんね。このステートをPageA
の内部に持つか、それとも前述のAppState
の一部にするかは一考の余地がありますが、どちらも一長一短です。
この設計では、「画面Bのデータをロード中の段階」は、PageA
のisLoading
ステートがtrue
になり、Root
がfetchUsers()
の結果を待っている段階として現れます。そして、「ロードが終わって画面Bをレンダリングする段階」はRoot
のsetState
でステートを変更して画面Bをレンダリングする部分に対応しています。
従来型設計の欠点と限界
この設計(従来型設計)で注目すべきは、ページ遷移に係るロジックがRoot
に集約されているという点です。ページ遷移というのはそもそもページ横断的なロジックなので、Root
が一枚噛んでいることは不自然ではありません。
しかし、「画面B用のデータを待つ」という機能をPageB
ではなくRoot
が担っている点が残念です。今回のように単純なパターンならば大きな問題にはなりませんが、Reactが提唱する「render-as-you-fetch」パターンを実装したいときに問題となります。また、細かいことをいえば、「fetchUsers()
の結果が帰ってきたらsetState
する」という処理は命令的な書き方であり、宣言的にUIを記述する流れに逆行しています。
ここで登場したrender-as-you-fetchパターンとは何かというと、複数のデータを表示してロードする際に、ロードできた部分から順次表示していくというパターンです。なるべく早く情報を表示するという目的のためにこの戦略が取られることもあるでしょう。そして明らかに、これを実現するには「データを待つ」という部分が画面Bの中で制御される必要があります。上述の「データがロードされるまで画面Bに制御を渡さない」という設計はこれと明らかに逆行しています。
さらに、これと上記の要件を組み合わせると、「画面Bのメインのデータがロードできるまでは画面Aに留まるが、それ以外のデータがまだでも画面Bに遷移して良い」みたいな仕様が誕生するかもしれません。これをそのまま実現しようとすると、データローディングのロジックがRoot
内と画面B内に分割され、設計が壊滅的状況に陥ります。
すぐに思い当たる解決策は「メインのデータのみRoot
で読み込んで、それ以外のデータは画面Bがレンダリングされた後にuseEffect
なり何なりから別途非同期処理を発火して読み込む」というものです。しかし、これには「メイン以外のデータの読み込みが画面Bがレンダリングされるまで始まらない」という致命的な問題があります。最近のWebアプリケーションにとってパフォーマンスは命なので、たかだか設計の都合程度の理由でデータ読み込み開始を送らせていいわけがありません。
ということで、ベストなUXを追求しようとすれば、手続き的なロジックにまみれた壊滅的な設計ができあがります。Concurrent Modeはこの状況に一石を投じました。
Concurrent Mode時代のデータローディング設計
前項で挙がった問題を纏めると、データを待つというロジックをRoot
が握っていること、ロジックが手続き的であること、そしてrender-as-you-fetchパターンが困難であることでした。
次は、これらの問題を解決するためのConcurrent Mode的設計パターンを見ていきます。まずRoot
はこのように書き換えられるでしょう。
type AppState =
| {
page: "A";
}
| {
page: "B";
usersFetcher: Fetcher<User[]>;
};
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} />;
}
};
まずRoot
内に目を向けると、fetchUsers()
はnew Fetcher()
の中に押し込まれました。これにより、goToPageB
が持つロジックはステートを画面Bのものに更新するだけになりました。
新しくPage
というコンポーネントができてstate.page
による分岐がPage
の中に入りましたが、これはページの外側にSuspense
を配置することが目的です。Suspense
コンポーネントをどこに配置すべきかは別途解説しますが、今回のようにページ遷移でサスペンドが発生するかもしれないときはページより外側に配置するのが適しています。いちいちgoToPageB
を受け渡す必要があるのがダサいと思われるかもしれませんが、それはコンテキストなり何なりを使って解消できるのであまり本質的な問題ではありません。
続いて、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>
);
};
isLoading
をuseState
で宣言するのをやめてuseTransition
を使うようになりました。画面Bへの遷移(goToPageB()
)をstartTransition
で囲むことで、遷移時にサスペンドが発生したらボタンにLoadinng...
が表示されるという制御がされています。
目ざとい方は、この設計は微妙だと思ったかもしれません。というのも、startTransition
は中でステートを更新することで意味を発揮する関数なのに、goToPageB
という関数は「画面Bに遷移する」という抽象化された意味を持たされており、中でステートの更新が行われることが明らかではありません。今回はgoToPageB
の実態がsetState({ ... })
なので偶々うまくいっていますが、startTransition
とsetState
という2つがセットで扱われないといけないことが設計に現れていないのがどうにも微妙です。
Reactの公式ドキュメントを読む限りはこれが大きな問題であるとは考えられていないようですが、個人的には改善の余地ありと感じるところです。
最後のPageB
は特筆すべきところがありませんが、一応出しておきます。
const PageB: FunctionComponent<{
usersFetcher: Fetcher<User[]>;
}> = ({ usersFetcher }) => {
const users = usersFetcher.get();
return (
<ul>
{users.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
};
以上のコードでは、最初に述べた従来の設計の3つの問題が解消されています。まず、「データを待つというロジックをRoot
が握っていること」及び「ロジックが手続き的であること」については、Root
が持つロジックがsetState
だけになったことによって解消されました。画面Bがデータを待つという部分も、Suspenseの機能およびFetcher
によって、手続き的な部分がReactの内部に隠蔽され、宣言的な書き方ができています。
最後の「render-as-you-fetchパターンが困難であること」については、この例が簡単なので現れていません。これについては次の記事で詳しく扱います。
まとめ
この記事では、ページ遷移という課題を例にとり、従来型の設計とConcurrent Mode時代の設計を比較し、Concurrent Modeによって従来存在した問題が解決できることを示しました。
尤も、何が問題で何か問題でないかということについて唯一解は存在しませんから、Concurrent Modeの視点からということにはなります。Reactはだんだんとopinionatedなライブラリの色を強くしてきていますから、この記事の内容に同意できなくてもそれは悪いことではありません。
この記事までが「Concurrent Mode時代のReact設計論」シリーズの前半です。前半ではConcurrent Modeの基礎を解説し、Concurrent Modeがどのような問題を解決したいのかについて示しました。
シリーズ後半では、Concurrent Modeを前提とした設計について議論します。先ほど少しだけ触れたように、この記事で出てきたConcurrent Modeのコードは従来の問題を解決しますが、これがベストな設計かどうかは疑う余地があります。次回以降の記事では、Concurrent Modeの恩恵をより受けるためにどのような設計がベストかについて考えていきます。