SuspenseとErrorBoundaryをどこに配置すべきか
問題背景
@asazutaiga: Suspense、ローディング時のUI表現を親に任せるものなので、それ凝集度下がってるのではないかみたいな気持ちになったりもするのですがどうなんですかね。Suspendするかどうかという知識が親側に漏れてるような気もするし。(もうやり尽くされた議論なんだろうが) Suspenseはサスペンドするコンポーネントと1対1で使うというよりはコンポーネントツリーという範囲に対して使えるという面でコンポネント内での条件分岐ではできなかったことができるようになっていて意味のある設計だとは思ってるんですけど、結局skeleton UIを作るとなると中のUIと密結合してる気も
@SoraKumo001: クライアントのSuspenseは、ローディング表示がコンポーネントレベルでの入れ替えになるので、大味なデザインになります。 ぶっちゃけ使いにくすぎです。
koushisa.icon
先に結論
クライアントでローディングやエラーを細かくハンドリングしたい(=非同期処理で解決したい問題があるか)どうか
全体で共通化されたローディングやエラーハンドリングでよければ親に配置してもよい
実装例:
「なぜこれらの機能を使いたいのか?」を明確にするべし
テスト戦略も踏まえて、依存関係をどう制御したいのかを問う
特定ライブラリに制御を委ねるとモックの手間がかかったり、エッジケースの対応に弱くなる
前提
もともとReactは設計や構造については開発者に委ねるという姿勢なので
要件に応じてその都度考えるべきという姿勢は正しいし、koushisa.iconとしても同意
一方、チーム開発においては一定の方針が欲しい
実装方針がバラバラだと後から困る場面が出てくる
ここを明らかにしていくのが趣旨である
ローディングを親に委ねるのか、それとも子でハンドリングするのか
基礎的な理論や機能は他のスクラップや公式ドキュメント参照
親は子のレイアウトに集中できる
子は自身の描画の責務に集中できる
koushisa.iconにとっては
理由
この規約を挟むことで副作用制御というをViewから分離できる 親は子を知る必要はないし、子は親を知る必要はない
空白
親にハンドリングを完全に任せてよいので非常にシンプルな実装になる
今回はトピック外とする
実現のためにはコンポーネントのマウント領域で最低限確保したい高さ, 幅を管理する必要がある
これはCSSやDOMの詳細に依存する
Suspenseを利用する場合は、配置と描画のコンポーネントは分離されることが多い 基本的な方針は以下がバランスがよいと思う
実装例
例
switchでコンポーネントを出し分ける
メリット
propsをある程度柔軟にカスタマイズできる
デメリット
koushisa.icon的にはこれで問題を感じたことはない
例
同階層でLoaderと描画コンポーネントを分けて書く
メリット
ローディング, エラーを追い出したので子はシンプルになる
デメリット
無理に分けたことで全体が却って追いづらくなっているように感じる
Loaderと描画コンポーネントは本質的に密結合な処理なのに分離されている
目が滑る
普通に前者でよいのでは
結論
実装例は後述する
目的に適した手段を選ぶべき
ここの考え方では、フロントエンドの機能の単位はコンポーネントというよりはウィジェットという単位になる ---
実装例
適当に書いたので動作確認はしてない
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やスタイリングで再利用しやすい
カプセル化しきれていない=(自己完結できていない)のが見方によっては気持ち悪い 一つのファイルから関連するコンポーネントをセットで公開する
一番スマートなのでオススメしたいkoushisa.icon
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>
)
}