It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

ReactのSuspenseコンポーネントことはじめ

概要

React18から正式に使えるようになったSuspenseという機能について、自身であまり積極的につかってこなかったため改めて、理解をまとめる。

公式ドキュメント

https://react.dev/reference/react/Suspense

整理

使い方

<Suspense fallback={<Loading />}>
  <Component />
</Suspense>

中断されるコンポーネントとはどんなものか

中断されるコンポーネントは、レンダリング中に「Promise を throw」する。

Promiseをthrowする とはどういう状態なのかというと、以下のように文字通り。Promiseインスタンスをthrow構文でスコープ外にスローする。

throw new Promise()

throw はエラー以外も投げられる

throw構文は、一般的にエラーに対する例外処理として使われ、頭がその作りだったため、「Promiseをthrowする」という設計について、理解が追いついていなかったが、Javascriptの構文上は、throwではエラーインスタンス以外のデータ型も使える。

よって当然、Promiseのインスタンスもthrowして、親コンポーネントで捉えることができる。

function throwNum() {
  throw 1;
}

function throwString() {
  throw "hoge";
}

function throwObj() {
  throw {
    type: "type",
    data: "data"
  };
}

function catchTest() {
  try {
    throwNum();
  } catch (e) {
    console.log(e);
  }
  try {
    throwString();
  } catch (e) {
    console.log(e);
  }
  try {
    throwObj();
  } catch (e) {
    console.log(e);
  }
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/throw

Promiseをthrowするデータ取得例

import { Suspense } from "react";

let executingPromise: PromiseWrapper<any> | undefined;

type State<T> =
  | {
      type: "pending";
      promise: Promise<T>;
    }
  | {
      type: "fulfilled";
      result: T;
    }
  | {
      type: "rejected";
      error: unknown;
    };

// Promiseの状態によって、Error, Promiseを適切にthrowするためのラッパークラス
class PromiseWrapper<T> {
  #state: State<T>;

  constructor(promise: Promise<T>) {
    const p = promise
      .then((result) => {
        this.#state = {
          type: "fulfilled",
          result: result
        };
        return result;
      })
      .catch((e) => {
        this.#state = {
          type: "rejected",
          error: e
        };
        throw e;
      });
    this.#state = {
      type: "pending",
      promise: p
    };
  }

  get(): T {
    switch (this.#state.type) {
      case "pending": {
        throw this.#state.promise;
      }
      case "fulfilled": {
        return this.#state.result;
      }
      case "rejected": {
        throw this.#state.error;
      }
    }
  }
}

// 実際の非同期データ処理
async function fetchTestData(): Promise<string> {
  // テストのため3秒遅延
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return "test data";
}

// データ取得処理
// Promiseの状態をみて、
// 処理中 => Promiseをthrowする
// 成功 => データを返す
// 失敗 => Errorをthrowする
function fetchData(): string {
  if (!executingPromise) {
    executingPromise = new PromiseWrapper(fetchTestData());
  }
  return executingPromise.get();
}

// 中断される可能性のあるコンポーネント
function SuspendedComponent() {
  const data = fetchData();
  return <div>{data}</div>;
}

export default function App() {
  return (
    <div className="App">
      <Suspense fallback={"loading..."}>
        <SuspendedComponent />
      </Suspense>
    </div>
  );
}

https://codesandbox.io/s/suspensenodetaqu-de-li-ddxjdk?file=/src/App.tsx

まず、中断される可能性があるコンポーネントSuspendedComponent でこれは、Suspenseで囲われる。

function SuspendedComponent() {
  const data = fetchData();
  return <div>{data}</div>;
}

SuspendedComponentは、useEffectなどは使わず直接関数コンポーネントのなかでfetchDataを呼び出す。

これが可能なのは、fetchDataが非同期ではなく同期的関数のため。

async function fetchTestData(): Promise<string> {
  // テストのため3秒遅延
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return "test data";
}

しかし、実際のデータ取得関数は、Promiseを返す非同期関数となっている。これはAPIからデータ取得を行う多くのケースでそうだと思う。

この非同期関数から返されるPromiseを同期的関数に変換するための仕組みとして PromiseWrapper というクラスが存在する。

class PromiseWrapper<T> {
  ...
  get(): T {
    switch (this.#state.type) {
      // 未完了ならpromiseをthrow
      case "pending": {
        throw this.#state.promise;
      }
      // 完了済みならそのまま結果を返す
      case "fulfilled": {
        return this.#state.result;
      }
      // 失敗していたらエラーをthrow
      case "rejected": {
        throw this.#state.error;
      }
    }
  }
}

このようなクラスでPromiseを保持しておき、同期的に取得可能な結果が得られない場合は、PromiseまたはErrorをthrowするという仕組みになっている。

こうすることで、fetchDataでは必ず同期的に結果が得られるようになり、もし非同期処理が終わっていない場合は、SuspendコンポーネントがthrowされたPromiseを引き続き監視し、fallbackのコンポーネントを表示してくれる。

https://codesandbox.io/s/s9zlw3?file=/Albums.js&utm_medium=sandpack

function use(promise) {
  if (promise.status === 'fulfilled') {
    return promise.value;
  } else if (promise.status === 'rejected') {
    throw promise.reason;
  } else if (promise.status === 'pending') {
    throw promise;
  } else {
    promise.status = 'pending';
    promise.then(
      result => {
        promise.status = 'fulfilled';
        promise.value = result;
      },
      reason => {
        promise.status = 'rejected';
        promise.reason = reason;
      },      
    );
    throw promise;
  }
}

Reactの公式サンプルでは、useという関数で同様なことをしており、処理されるPromise自体はMapでキャッシュされている。

※ 同じような概念かもしれないが、Reactでは、ライブラリ実装として、use というhooksが提供されているが、これとは別物。こちらもいずれ深掘りしたい。

Errorがthrowされたらどうするのか?

https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary

<ErrorBoundary fallback={"エラーが発生しました"}>
  <Suspense fallback={<Loading />}>
    <Component />
  </Suspense>
</ErrorBoundary>

既存の非同期処理をSuspenseに置き換えるには

上記の通りで、既存のコードでuseEffect等でasync関数によりデータ取得を行っている場合、そのままSuspenseコンポーネントに置き換えることはできない。Promiseをthrowする仕組みを作る必要がある。

この仕組みを実装するのは割りとコストにみえるので、対応しているライブラリを使うのが一般的ななのかもしれない?

https://github.com/tanstack/query

https://swr.vercel.app/ja/docs/suspense

参考

https://qiita.com/uhyo/items/255760315ca61544fe33

throw Promiseの概念がしっくりこなかったのでとても参考になりました。

サンプルコードの大半もこちらを参考にさせていただいています。

今後深掘りしたいこと

  • ErrorBoundary
  • useDeferredValue
  • startTransition