LoginSignup
1
1

More than 1 year has passed since last update.

RecoilのLoadableクラスのようなものを作ってSuspenseさせてみる

Posted at

はじめに

Reactの状態管理ライブラリの一つであるRecoilにはLoabableというオブジェクトが存在します。
このオブジェクトは主に非同期データを状態として扱う時に利用されます。現在の状態や値、さらにそれに対する操作などが定義されています。
React18以前のバージョンでは非同期データを状態として扱う時によく使われますが、Suspenseが安定したReact18以降ではあまり使われないです。
この記事ではLoadableオブジェクトのようなものを実装することで素のReactにおけるSuspenseの扱いが綺麗になるのではと考えて実装したものを紹介します。結論を言うとあまり綺麗なフローを描くことが出来ませんでしたのでこの方法でアプリを書くことはお勧めできません。

Recoilについてはこちらの記事、非同期データの扱いについては別記事をごご覧ください。

Loadableとは

Loadableとは状態の状況を表すオブジェクトです。Recoilの実装はこちらです。
このオブジェクトは状況を表す三つの値を持つstatestateに応じた値を持つcontentsをプロパティとして持ちます。
stateは状態が確定していることを示す'hasValue'、状態の取得でエラーが起きたことを示す'hasError'、状態の取得中であることを示す'loading'を持ちます。
contentsstatehasValueの時は状態が持つ値を、hasErrorの時はErrorオブジェクトを、loadingの時は解決中のPromiseを返します。
さらにLodableオブジェクトは10個のメソッドを持っています(安定した機能ではないことに気をつけてください)。getValuetoPromiseerrorOrThrowなどがありますが、この記事では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にアクセスしてstatecontentsを取得できるようにしました。loadableの型はValueLoadableLoadingLoadableErrorLoadableのようにstateごとに分割しました。
constructorではデータを取得するための非同期関数fetcherを受け取ってloadblestatecontentsを詰めていきます。最初は読み込み中の状態なのでstate'loading'contentにはfetcherに成功後の処理とエラー時の処理を含めて配置しました。追加した成功後の処理とエラー時の処理ではloadbleの再更新を行わせます。成功後の処理ではloadablestate'hasValue'contentsを成功結果を渡すようにしています。エラー時の処理ではloadablestate'hasError'contentsをエラーを渡すようにしています。これによってLoadableを作成したタイミングで処理が走り、完了後は成功または失敗の状態に陥る処理を作ることが出来ました。
そして、最後にstate'hasValue'の時だけcontentsを返してそれ以外の時はcontentsを投げるようなメソッドvalueOrThrowを定義しています。型を適切に定義したので分岐させているだけですね。

Suspenseを試す

このLoadableオブジェクトを利用してSuspenseさせるコンポーネントを実装してみます。

App.tsx
export default function App() {
  const [loadable] = useState(() => new Loadable(fetcher));
  return (
    <div>
      <Suspense fallback={<p>Loading...</p>}>
        <Value loadable={loadable} />
      </Suspense>
    </div>
  );
}
Value.tsx
export const Value = <T extends string | number>({
  loadable
}: {
  loadable: Loadable<T>;
}): JSX.Element => {
  const value = loadable.valueOrThrow();
  return <>{value}</>;
};

この例では親コンポーネントでLoadableを呼び出して、その子コンポーネントのValuevalueOrThrowを使って解決するまでLoading画面を出すようにしています。
このようにデータを扱うためにはLoadableを親のコンポーネントで呼び出して、扱うコンポーネントに渡してvalueOrThrowを発火させると言う仕組みを組む必要があります。コンポーネントでデータを扱うために親で呼び出す必要があるのはあまり良い仕組みとは考えられません(それが良い時もありますが、強制されるのは良くないと考えています)。ここがはじめにで話したお勧めできない理由です。
このようになる理由はSuspenseが解決した後はそのコンポーネントをレンダリングし直すので、同じコンポーネントでLoadableを作成した場合は解決するたびにLoadableが作り直されてまたデータの読み込みが始まりSuspenedされてしまうように無限ループに陥るからです。そのため、ただ親に置けばいいだけではなくSuspenseはからならず親に書く必要があります。

まとめ

素のReactで綺麗にSuspenseを使いたかったのですが、残念ながらできませんでした。Loadableの作り込みが甘いことが原因の可能性もあるのでそこを強化してさらなる改善を積んでみます。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1