SuspenseとErrorBoundaryをどこに配置すべきか
#宣言的UIの設計レシピ #React #設計
問題背景
@asazutaiga: Suspense、ローディング時のUI表現を親に任せるものなので、それ凝集度下がってるのではないかみたいな気持ちになったりもするのですがどうなんですかね。Suspendするかどうかという知識が親側に漏れてるような気もするし。(もうやり尽くされた議論なんだろうが)
Suspenseはサスペンドするコンポーネントと1対1で使うというよりはコンポーネントツリーという範囲に対して使えるという面でコンポネント内での条件分岐ではできなかったことができるようになっていて意味のある設計だとは思ってるんですけど、結局skeleton UIを作るとなると中のUIと密結合してる気も
@SoraKumo001: クライアントのSuspenseは、ローディング表示がコンポーネントレベルでの入れ替えになるので、大味なデザインになります。
ぶっちゃけ使いにくすぎです。
koushisa.icon
Render As You FetchパターンにおいてSuspenseとError Boundaryはどこに配置すべきかについて深堀りする
過去にも似たような思考実験はしている
Render As You FetchとThe five UI states
Suspense = whatとhowの分離
コンポーネント(ウィジェット)の境界線
#マイクロフロントエンド #React_Server_Components
先に結論
設計判断のポイントはThe five UI statesの要件次第であり、その中でも大きな変数はSkeleton UIが必要かどうか
クライアントでローディングやエラーを細かくハンドリングしたい(=非同期処理で解決したい問題があるか)どうか
全体で共通化されたローディングやエラーハンドリングでよければ親に配置してもよい
Skeleton UIを扱うケースではそもそもSuspense, Error Boundaryを利用しないほうが簡潔でよい
実装例:
Recoil > Loadable#632bb4c78660300000bec185
SuspenseとErrorBoundaryをどこに配置すべきか#63feab4b8660300000e73f20
「なぜこれらの機能を使いたいのか?」を明確にするべし
テスト戦略も踏まえて、依存関係をどう制御したいのかを問う
特定ライブラリに制御を委ねるとモックの手間がかかったり、エッジケースの対応に弱くなる
カーゴカルトプログラミング
生殺与奪の権を他人に握らせるな
Recoil > Loadableのように非同期を代数的データ型(ADT)でパターンマッチングできると、状態を透過的に扱えるため便利
前提
Reactは基本的にunopinionated
error boundaryの粒度はあなた次第です
もともとReactは設計や構造については開発者に委ねるという姿勢なので
要件に応じてその都度考えるべきという姿勢は正しいし、koushisa.iconとしても同意
一方、チーム開発においては一定の方針が欲しい
実装方針がバラバラだと後から困る場面が出てくる
ここを明らかにしていくのが趣旨である
ローディングを親に委ねるのか、それとも子でハンドリングするのか
SuspenseとError Boundaryについて
基礎的な理論や機能は他のスクラップや公式ドキュメント参照
これを利用するとAlgebraic Effectsという仕組みで副作用制御を親コンポーネントに委譲できる
Simpleな規約に従うことで疎結合かつ高凝集を達成できる
関心事の分離によりコードベース全体ががおいやすくなる
親は子のレイアウトに集中できる
子は自身の描画の責務に集中できる
起点はここ: 関心の分離はドメインとプレゼンテーションから考える(PDS)
SuspenseやError Boundaryはドメイン or プレゼンテーション?
koushisa.iconにとっては
プレゼンテーションで、かつステレオタイプ > バウンダリ相当の機能と捉えている
理由
状態を持たないドメインと状態を持つUIのインタフェース = 一種の規約と捉えているから
この規約を挟むことで副作用制御というをViewから分離できる
本題: SuspenseとError Boundaryはどこに配置すべきものなのか?
オープン・クローズドの原則(Open Closed Principle,OCP)によると
親は子を知る必要はないし、子は親を知る必要はない
The five UI statesを例にして考える
The five UI states > Loading state:ロードしている状態状態の表現にはいくつか種類がある
空白
Spinner UI
Skeleton UI
空白, Spinner UIの場合はあまり何も考えなくても良い
親にハンドリングを完全に任せてよいので非常にシンプルな実装になる
UI/UXやCore Web Vitals > CLS: 視覚的な安全性的には望ましいとは思わないが...koushisa.icon
今回はトピック外とする
問題はSkeleton UIである
実現のためにはコンポーネントのマウント領域で最低限確保したい高さ, 幅を管理する必要がある
これはCSSやDOMの詳細に依存する
Suspenseを利用する場合は、配置と描画のコンポーネントは分離されることが多い
SuspenseによるSkeleton UIの描画とフェッチ後の描画がファイルをまたいでしまうとheight, widthの共用が難しく影響範囲が広がるためメンテナンス性が悪い
基本的な方針は以下がバランスがよいと思う
SuspenseとError Boundaryを無理して使う必要はない
子の中にSkeleton UIを提供するレイヤを設ける
The five UI statesのハンドリングは極力同階層で行う
実装例
Container/Presenterパターン
例
Recoil > Loadable#632bb4c78660300000bec185
switchでコンポーネントを出し分ける
メリット
処理の粒度が整うので理解容易性が高い
propsをある程度柔軟にカスタマイズできる
デメリット
koushisa.icon的にはこれで問題を感じたことはない
強いて言うとしたら、Recoil > Loadableのような規約やパターンマッチングできる仕組みがアプリケーションに必要 = 依存が増え複雑性が高まる可能性がある
Render As You Fetchパターン
例
Render As You FetchとThe five UI states#632bb96786603000001072af
同階層でLoaderと描画コンポーネントを分けて書く
メリット
ローディング, エラーを追い出したので子はシンプルになる
Spinner UIであれば親でSuspenseで囲むだけで十分なので、この上なくシンプル
デメリット
無理に分けたことで全体が却って追いづらくなっているように感じる
Loaderと描画コンポーネントは本質的に密結合な処理なのに分離されている
目が滑る
普通に前者でよいのでは
結論
実装例は後述する
基本的にはUI/UX要件により戦略は変わる
無理にRender As You Fetchパターンに当てはめる必要はない
目的に適した手段を選ぶべき
SuspenseとError Boundaryはどこに配置すべきものなのか?の疑問の前に「なぜ使いたいのか?」を明確にする
Recoil > Loadableはここをうまく抽象化していて、アプリの戦略により自由に非同期処理の戦略を取ることが出来る
決断を遅らせる
IslandArchitectureやマイクロフロントエンドの文脈
ここの考え方では、フロントエンドの機能の単位はコンポーネントというよりはウィジェットという単位になる
昨今のReactを見ていると、末端のコンポーネントを賢くしており, ウィジェットやSmart UIに近づいている
React Server Components
Smart UI
Smalltalk MVC
こういったケースではSuspense Error Boundaryも必然的にコンポーネントとコロケーションさせることになるはず
---
実装例
1. Higher-Order Componentで包括する
適当に書いたので動作確認はしてない
code:tsx
type WithLoaderOption<T> = {
resource: RecoilValueReadOnly<T>,
Component: (data: T) => React:ReactNode,
Loading: React.ReactNode,
Error: (error:unknown) => React.ReactNode,
}
// HoC
const withLoader = <T>(option: WithLoaderOption<T>) => {
const {Component, Loading, Error, resource} = option
const data = useRecoilValueLoadable(resource)
switch (data.state) {
case 'hasValue':
return <>{Component(data.contents)}</>;
case 'hasError':
return <>{Error(data.contents)}</>;
case 'loading':
return <>{Loading}</>;
}
}
---
// 利用側
const height = 80
const width = 80
type ArticlesProps = {
articles: Article[]
}
const articlesQuery = selector<ArticlesProps"articles">({
key: "articlesQuery",
get: ({get}) => {
return fetchArticles(get(params))
}
})
const Articles = (props: ArticlesProps) => {
const { articles } = props
return (
<AwesomeAnimationBox height={height} width={width}>
{articles.map(/*=*/)}
</AwesomeAnimationBox>
)
}
// HoCを使って全ての処理をまとめる
const ArticlesWithSkeleton = withSkeleton({
resource: articlesQuery,
Component: (data) => <Articles articles={data} />
Loading: <Skeleton height={height} width={width} />
Error: (err) => <Error height={height} width={width} />
})
export { ArticlesWithSkeleton as Articles}
コロケーションっぽい感じ
HoCに逃したことで、Articlesが純粋なViewコンポーネントにできるのは利点(HowとWhatの分離)
2. height, widthを取得する関数を公開する
code:tsx
const height = 80
const width = 80
const Articles = () => {
const articles = useRecoilValueLoadable(articlesQuery)
return (
<AwesomeAnimationBox height={height} width={width}>
{articles.map(/*=*/)}
</AwesomeAnimationBox>
)
}
// サイズを取得する関数を公開する
Articles.getSize = () => {
return { height, width }
}
export { Articles }
---
//利用側
const ArticlesPage = () => {
// 単純にgetSizeを呼び出してheight, widthを取得できる
const size = Articles.getSize()
return (
<ErrorBoundary {...size}>
<Suspence fallback={<Skeleton {...size} } />}>
<Articles />
</Suspense>
</ErrorBoundary>
)
}
height, width定数を公開すると言うよりは、関数を公開するというのがポイントなのかも
コンポーネントと密結合なので${Component}.getSize()とできると主語がわかりやすい
import/exportの治安がよくなる
CSSやスタイリングで再利用しやすい
カプセル化しきれていない=(自己完結できていない)のが見方によっては気持ち悪い
3. Compoundコンポーネントのようにする
カプセル化したい重要なドメイン知識はドメインモデルに凝集させる
一つのファイルから関連するコンポーネントをセットで公開する
一番スマートなのでオススメしたいkoushisa.icon
Vercelの例
https://github.com/vercel/app-playground/blob/14d123160619e45bacf17a2afd59fe4eb8d1ca17/app/streaming/_components/reviews.tsx#L32
https://github.com/vercel/app-playground/blob/14d123160619e45bacf17a2afd59fe4eb8d1ca17/app/streaming/node/product/%5Bid%5D/page.tsx#L48-L63
code:tsx
const Articles = () => {
const articles = useRecoilValueLoadable(articlesQuery)
return (
<>
{articles.map(/*=*/)}
</>
)
}
const Skeleton = () => {
<div className="space-y-4">
<div className="h-6 w-2/6 rounded-lg bg-gray-900" />
<div className="h-4 w-1/6 rounded-lg bg-gray-900" />
<div className="h-4 w-full rounded-lg bg-gray-900" />
<div className="h-4 w-4/6 rounded-lg bg-gray-900" />
</div>
);
}
const ErrorBoundary = (children) => {
return (
<ErrorBoundary>
{children}
</ErrorBoundary
)
}
const Box = (children) => {
return (
<ErrorBoundary>
<Suspence fallback={<Skeleton/>}>
<AwesomeAnimationBox height={height} width={width}>
{children}
</AwesomeAnimationBox>
</Suspense>
</ErrorBoundary>
)
}
// Compound
Articles.Box = Box
export { Articles }
---
//利用側
const ArticlesPage = () => {
return (
// Compoundを利用する
<Articles.Box>
<Articles/>
</Articles.Box>
)
}
TODO: モードレスUIやウィジェットだとどう考えるだろうか?