Suspenseを活用するために手に入れたいメンタルモデルを考える


React の Suspense 、活用していますか?

Suspense はデータのローディング状況を管理するのに便利なコンポーネントで、v18で正式リリースされています。この Suspense ですが、なかなか活用が難しいのです。

というのも、従来のデータ取得プロセスをベースにした考え方と大きく異なるため、これまでのメンタルモデルでは理解が困難で、十分に活用するためには多くの学習コストを支払う必要があります。

わたしたち Gaji-Labo は Next.js を活用したプロダクトチームの支援を得意としています。Next.js の進化は目まぐるしいものがありますが、そのなかでも Suspense は重要な位置をしめていると考えます。

この記事では、Suspenseがどのようなものかを知り、十分に活用するために必要な新しいメンタルモデルについて考えていきます。

Suspense はどう働くか

まず、Suspenseのことを知りましょう。どのように動く機能でなにをしてくれるのか、公式ドキュメントから情報を集めます。

<Suspense> を使うことで、子要素が読み込みを完了するまでフォールバックを表示させることができます。

function Albums({ artistId }) {
  const albums = use(fetchData(`/${artistId}/albums`));
  return (
    <ul>
      {albums.map(album => (
        <li key={album.id}>
          {album.title} ({album.year})
        </li>
      ))}
    </ul>
  );
}

function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Albums artistId={artist.id} />
      </Suspense>
    </>
  );
}

処理の流れをさらうと、どうやらこのような事をしています。

  1. ArtistPage がアーティストの情報を受け取り
  2. artist.id を使ってアルバムの情報を非同期通信で取得
  3. アルバムの情報がロードされるまでの間、 <Loading /> を表示
  4. アルバムの情報のロードが完了したら、 <Albums /> でアルバムリストを描画

コードの中に見慣れないものが2つあります。 useSuspense です。これらが Suspense の機能を動かす核となります。これらのAPIが何をしてくれるのかを掘り下げていきましょう。

Suspense は何をしてくれるか

まずは Suspense の動作を調べてみました。単純化すると、次のような事をしてくれるようです。

  1. 子コンポーネントから throw された Promise をキャッチする
  2. その Promise が解決されるまでの間、fallback を表示する
  3. Promise が解決されたら children を描画

頭の中でイメージしやすいようにコードで表現してみましょう。

// このコードはイメージです(正しく動作しません)
function FakeSuspense({
  children,
  fallback,
}: { children: ReactNode; fallback: ReactNode }) {
  try {
    return children;
  } catch (error) {
    // Promiseがthrowされた場合
    if (error instanceof Promise) {
      // Promiseが解決されるまでfallbackを表示
      return fallback;
    }
    // その他のエラーは上位に伝播
    throw error;
  }
}

このコードは機能をイメージしやすくするためのもので、正しく動かないのでうっかり試さないようにご注意ください。(動かない理由はここでは割愛します)

ここでおさえておきたいのは、Suspense を機能させるためには children から Promisethrow しなければならないということです。 従来 Promise.then()await などで値を受け取るのが通例でした。それを throw させているのが Suspense の大きな特徴ですね。

use は何をしてくれるか

続いて use の動作を調べてみます。(なんという抽象度の高い命名なのでしょうか!)Suspense と連携する時の use は次のような流れで処理をおこなうようです。

  1. 引数に Promise を受け取る
  2. Promise が解決されるまでの間、それを throw する
  3. Promise が解決されると、解決された値を返却する

こちらもイメージしやすいようにコードで表現してみます。

// このコードはイメージです(正しく動作しません)
const promiseCache = new WeakMap();

function fakeUse<T>(promise: Promise<T>): T {
  // Promise はキャッシュされている必要がある
  if (!promiseCache.has(promise)) {
    promiseCache.set(promise, { status: "pending" });
    promise.then((result) => {
      promiseCache.set(promise, { status: "fulfilled", result });
    });
  }

  // キャッシュから状態を取得
  const cache = promiseCache.get(promise);
  if (cache.status === "pending") {
    throw promise; // Suspenseにキャッチさせるためにthrowする
  }

  return cache.result; // 解決済みの結果を返す
}

もちろんこのコードも正しく動きませんので、実際に使わないでくださいね。使うと無限ループに陥ったりします。

さて、useSuspense の関係性がなんとなくつかめたでしょうか。 use から投げられた PromiseSuspense がキャッチすることでこの機能は実現しています。データ取得の責務は children に負わせ、 Suspense はキャッチした Promise からロード状況を把握してローディング状態を管理しているのです。

従来の開発方法と比較する

Suspense を大枠理解したところで、従来の開発方法とどう違うのかを考えてみます。比較のため、上の例と同じ処理をしてみます。このコードはイメージです。

// このコードはイメージです
export function ArtistPage({ artist }) {
  const [albums, setAlbums] = useState([]);

  useEffect(() => {
    (async () => {
      const data = await fetchData(`/${artist.id}/albums}`);
      setAlbums(data);
    })();
  }, []);

  if (!albums.length) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <h1>{artist.name}</h1>
      <Albums albums={albums} />
    </>
  );
}

馴染みのある見た目のコードですね。説明の必要はないかもしれませんが、以下のような処理をしています。

  1. データを取得する
  2. データのロードが完了したらステートを更新する
  3. ステートが更新されるまでの間、 Loading... を表示する
  4. ステートが更新されたらコンテンツを描画する

得られる結果としては、Suspenseとそう変わりません。

Suspense実装との比較

では、このトラディショナルなコードと Suspense による実装ではどのような違いがあるのでしょうか。

最も分かりやすい違いは、Suspense が投げられた Promisecatch するのに対して、トラディショナルなコードでは Promise で解決されるデータを await で受け取っている点ですね。

そしてもうひとつ大切なのは、責務の所在の違いです。従来の方法ではデータの取得もローディングの管理も責務を負っているのは親コンポーネントです。対して Suspense では、データの取得は子コンポーネントが、ローディングの管理は Suspense コンポーネントが責務を負っていて、親コンポーネントはそれらを配置するだけです。関心の分離がなされています。

Suspense のメンタルモデルを考える

さて本題です。上で述べたようなトラディショナルな実装とSuspenseの差分から、Suspense のメンタルモデルを考えていきます。この差分がつまり、考え方をアップデートしなければならない部分だと考えます。

  1. データ管理の責務をどこが負うか
  2. データはどのように渡ってくるか

ひとつずつ理解を進めてみましょう。

1. データ管理の責務をどこが負うか

Suspense では、データ管理の責務はそのデータを使いたいコンポーネントが負います。

従来の方法では、データ取得もローディングの管理も、親コンポーネントが一元的におこなっていました。Suspense ではそのデータを必要とするコンポーネントから直接データを取得し、ローディング状態はそれをラップする Suspense コンポーネントが管理します。

つまり、データを管理するスコープが異なります。

トラディショナルな実装ではページ全体、あるいはアプリ全体がスコープとなり、言ってみれば中央集権的な管理となります。対して Suspense 実装では、 Suspense コンポーネントをブロックとしてスコープが区切られます。データ管理はそのデータを必要とするコンポーネントでおこなう形で実装されます。

親コンポーネントはデータ管理に関心をもつ必要がなくなり、設計はよりシンプルになっていきます。

2. データはどのように渡ってくるか

Suspense では、データは下から浮かび上がってきます。

従来は、親コンポーネントで取得したデータを、それを必要とする子孫コンポーネントに渡すためにバケツリレーをしたり、状態管理ライブラリを活用したりしていました。つまり、上からデータが降りてくる、トップダウンであると言えます。

対して Suspense では、下層から throw された Promisetry ... catch でキャッチするわけで、データは下からやってきます。イメージとしては JavaScript のイベントバブリングの機構とも似ていますね。データはバブリングしてきます。

データは親コンポーネントを通過してきません。ですので、親コンポーネントはデータの事を知りません。知る必要がないということです。

おわりに

この記事では React の Suspense について、

  • Suspense がどう動くか
  • それはトラディショナルなデータ管理とどう違うのか
  • その違いから、Suspense のメンタルモデルをどう考えるか

という観点で考えました。

トラディショナルな方法は React誕生以前から続いてきた長い実績のあるもので、そこから新しい方法へシフトするためにはメンタルモデルを新たに構築する必要があります。それはとても労力を要することです。

この記事が、Suspense の動きを頭でイメージするための一助になれば幸いに思います。

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります

フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。

「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」

フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。

オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。

Next.js, React, TypeScript の相談をする!

投稿者 Oikawa Hisashi

フロントエンドエンジニア。モダンなJavaScript開発に関心があります。 デザインからバックエンドまで網羅的にこなすマルチデザイナーとして長く活動してきた経験を活かして、これから関わる様々なものをデザインしていきたいです。チームもコミュニケーションもデザインするもの。ライフワークはピアノと水泳。