はじめに
Reactの状態管理ライブラリの一つであるRecoilにはLoabableというオブジェクトが存在します。
このオブジェクトは主に非同期データを状態として扱う時に利用されます。現在の状態や値、さらにそれに対する操作などが定義されています。
React18以前のバージョンでは非同期データを状態として扱う時によく使われますが、Suspense
が安定したReact18以降ではあまり使われないです。
この記事ではLoadable
オブジェクトのようなものを実装することで素のReactにおけるSuspense
の扱いが綺麗になるのではと考えて実装したものを紹介します。結論を言うとあまり綺麗なフローを描くことが出来ませんでしたのでこの方法でアプリを書くことはお勧めできません。
Recoilについてはこちらの記事、非同期データの扱いについては別記事をごご覧ください。
Loadableとは
Loadable
とは状態の状況を表すオブジェクトです。Recoilの実装はこちらです。
このオブジェクトは状況を表す三つの値を持つstate
とstate
に応じた値を持つcontents
をプロパティとして持ちます。
state
は状態が確定していることを示す'hasValue'
、状態の取得でエラーが起きたことを示す'hasError'
、状態の取得中であることを示す'loading'
を持ちます。
contents
はstate
がhasValue
の時は状態が持つ値を、hasError
の時はError
オブジェクトを、loading
の時は解決中のPromise
を返します。
さらにLodable
オブジェクトは10個のメソッドを持っています(安定した機能ではないことに気をつけてください)。getValue
、toPromise
、errorOrThrow
などがありますが、この記事ではvalueOrThrow
だけを実装します。valueOrThrow
は解決済みの値を持っている場合はそれを返却して、読み込み中だった場合は解決中のPromise
を投げ、解決中にエラーが生じた場合はそのError
オブジェクトを投げます。
Loadableの実装
データを取得するための非同期関数fetcher
を引数とするLoadable
クラスを作成します。
type ValueLoadable<T> = {
state: "hasValue";
contents: T;
};
type LoadingLoadable<T> = {
state: "loading";
contents: Promise<T>;
};
type ErrorLoadable = {
state: "hasError";
contents: unknown;
};
export class Loadable<T> {
loadable: ValueLoadable<T> | LoadingLoadable<T> | ErrorLoadable;
constructor(fetcher: Promise<T>) {
this.loadable = {
state: "loading",
contents: fetcher
.then((data) => {
console.log(1);
this.loadable = {
state: "hasValue",
contents: data
};
return data;
})
.catch((error) => {
this.loadable = {
state: "hasError",
contents: error
};
throw error;
})
};
}
valueOrThrow(): T {
switch (this.loadable.state) {
case "loading":
throw this.loadable.contents;
case "hasValue":
return this.loadable.contents;
case "hasError":
throw this.loadable.contents;
}
}
}
型を精密に持たせる都合上Loadable
オブジェクトのloadable
にアクセスしてstate
とcontents
を取得できるようにしました。loadable
の型はValueLoadable
、LoadingLoadable
、ErrorLoadable
のようにstate
ごとに分割しました。
constructor
ではデータを取得するための非同期関数fetcher
を受け取ってloadble
にstate
とcontents
を詰めていきます。最初は読み込み中の状態なのでstate
に'loading'
、content
にはfetcher
に成功後の処理とエラー時の処理を含めて配置しました。追加した成功後の処理とエラー時の処理ではloadble
の再更新を行わせます。成功後の処理ではloadable
のstate
を'hasValue'
にcontents
を成功結果を渡すようにしています。エラー時の処理ではloadable
のstate
を'hasError'
にcontents
をエラーを渡すようにしています。これによってLoadable
を作成したタイミングで処理が走り、完了後は成功または失敗の状態に陥る処理を作ることが出来ました。
そして、最後にstate
が'hasValue'
の時だけcontents
を返してそれ以外の時はcontents
を投げるようなメソッドvalueOrThrow
を定義しています。型を適切に定義したので分岐させているだけですね。
Suspenseを試す
このLoadable
オブジェクトを利用してSuspense
させるコンポーネントを実装してみます。
export default function App() {
const [loadable] = useState(() => new Loadable(fetcher));
return (
<div>
<Suspense fallback={<p>Loading...</p>}>
<Value loadable={loadable} />
</Suspense>
</div>
);
}
export const Value = <T extends string | number>({
loadable
}: {
loadable: Loadable<T>;
}): JSX.Element => {
const value = loadable.valueOrThrow();
return <>{value}</>;
};
この例では親コンポーネントでLoadable
を呼び出して、その子コンポーネントのValue
でvalueOrThrow
を使って解決するまでLoading画面を出すようにしています。
このようにデータを扱うためにはLoadable
を親のコンポーネントで呼び出して、扱うコンポーネントに渡してvalueOrThrow
を発火させると言う仕組みを組む必要があります。コンポーネントでデータを扱うために親で呼び出す必要があるのはあまり良い仕組みとは考えられません(それが良い時もありますが、強制されるのは良くないと考えています)。ここがはじめにで話したお勧めできない理由です。
このようになる理由はSuspense
が解決した後はそのコンポーネントをレンダリングし直すので、同じコンポーネントでLoadable
を作成した場合は解決するたびにLoadable
が作り直されてまたデータの読み込みが始まりSuspenedされてしまうように無限ループに陥るからです。そのため、ただ親に置けばいいだけではなくSuspense
はからならず親に書く必要があります。
まとめ
素のReactで綺麗にSuspenseを使いたかったのですが、残念ながらできませんでした。Loadable
の作り込みが甘いことが原因の可能性もあるのでそこを強化してさらなる改善を積んでみます。