React Suspenseの論点


React18がリリースされました。公式のブログにも書かれている通り、18は今後マイナーバージョンアップを重ね、そして周りのエコシステムが追従してくることで完成される、いわば未完成な状態でスタートしています。

一方、私達はすでにReact18をインストールし使うことができます。今日は、中でも以前より注目を浴び、難産の末ようやく日の目を見たSuspenseについて取り上げます。(※限定的な機能で以前からありました)

まだエコシステムも追従していない中でSuspenseについて「Suspenseはこう使え」みたいな議論をするのは時期尚早と思いますので、本記事ではSuspenseについて我々が考えるべき論点はなにかを考えてみたいと思います。

なお、Suspenseのユースケースは様々ですが、今回は最もアプリケーションエンジニアにとって身近になりうるSuspense for Datafetchingを主に言及します。

はじめに

実際の動きや基本知識は他の記事に任せるとして、サクッとAPIを確認しましょう。

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

Commentsの中でsuspend状態(promiseがthrowされた状態)になると、Commentsコンポーネントはレンダリングされず、Suspenseのfallbackが表示されます。基本はシンプルですね。

Suspenseのリファレンス

Suspenseの公式からのドキュメントは色々なところに散らばっていてやや情報を集めづらいですが、下記のリンクを見るとわかりやすいかと思います。

Suspenseの現在の仕様

https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md

Suspenseの今後

https://github.com/reactwg/react-18/discussions/47#discussioncomment-847004

それでは早速、Suspenseの論点について1つずつ見ていきましょう。

論点1: Suspense前後でコンポーネント設計はどう変わるか

Suspense導入前後でコンポーネントはどう変わるでしょうか。自分の現状の感覚では、より「データフェッチは、そのデータが必要なコンポーネントのなるべく近くで行う」という方向が強まるのではと思っています。これは、colocationという名前で知られReactのコンポーネント設計の文脈で言及されることも多いですが、今後Suspenseによって加速していくと考えています。

例えば次のようなコードは現状良く書かれているのではないでしょうか。ポイントは、①データロード層と②データディスプレイ層が分離していることです。

function App() {
  // ①データロード層
  const [res] = useQuery({query: ListProducts})
  
  return (
    <div>
      <p>商品一覧</p>
      <div>
        // ②データディスプレイ層
        {res.fetching && <Spinner />}
        {res.data?.products && res.data.products.map(p => (
          <div>
            <div>{p.name}</div>
            <div>{p.price}</div>
          </div>
        ))}
      </div>
    </div>
  )  
}

このコードは、Suspenseを使うと下記のようになります。

function App() {
  return (
    <div>
      <p>商品一覧</p>
      <Suspense fallback={<Spinner />}>
        <ProductList />
      </Suspense>
    </div>
  )  
}

function ProductList() {
  // ①データロード層
  const [res] = useQuery({query: ListProducts})
  // ①データディスプレイ層
  return (
    {res.data?.products && res.data.products.map(p => (
      <div>
        <div>{p.name}</div>
        <div>{p.price}</div>
      </div>
    ))}
  )
}

データロード層とデータディスプレイ層がより近くなりました。このとき、useQueryはsuspenseに対応したものである必要がありますが、その場合、useQueryの記述をSuspenseの外にやってしまうと当然動きませんので、このようにcolocationが自然と達成されたコードになっていきます。

render-as-you-fetchやgraphqlのfragment colocationでは

なお、データフェッチ層ではなくデータディスプレイ層と書いたのは意図があります。やや込み入った話になりますが、render-as-you-fetchやgraphqlのfragment colocationをうまく使うと、より上位階層でフェッチする処理をまとめ、実際にデータが習得できた順にUIに反映していくみたいなことができるようになります。この挙動はrelayなんかではpartial renderingと呼ばれりします。

まだ周辺ライブラリ等の対応が必要ですが、この実装が可能になると、ページのトップレベルでデータフェッチを行い、データが必要なコンポーネントでデータディスプレイのhookを使い(これがsuspense対応されている)、そのコンポーネントはそれぞれ独立したSuspenseでラップされる、みたいなコードが増えてくるのではないかと思っています。

雑に書くとこんな感じでしょうか。(特定のライブラリをイメージして書いているわけではなく、架空のライブラリを使っています)

function App() {
  fetchQuery({query: RootQuery}) // 省略。fragmentなり下部レイヤーのデータリクエストをまとめる

  return (
    <div>
      <p>商品一覧</p>
      <Suspense fallback={<Spinner />}>
        <ProductList />
      </Suspense>
      <p>タグ一覧</p>
      <Suspense fallback={Spinner />}>
        <TagList />
      </Suspense>
    </div>
  )  
}

function ProductList() {
  const [data] = useDataSource({query: ListProducts})
  return (
    {data && data.products.map(p => (
      <div>
        <div>{p.name}</div>
        <div>{p.price}</div>
      </div>
    ))}
  )
}

function TagList() {
  const [data] = useDataSource({query: ListTags})
  return (
    {data && data.tags.map(p => (
      <div>
        <div>{p.name}</div>
      </div>
    ))}
  )
}

このコードはSuspenseを並列においていますが、複雑な画面やUI最適化をする場合Suspenseがネストされることもあるでしょう。

ローディング状態のUIはグローバルスピナーからローカルスピナーへ

このようにSuspenseを利用していくと、グローバルなローディングスピナーよりも局所的なスピナーを使うようになります。これは以前であればやや面倒な処理だった部分ですが、Suspense導入にあたってはユーザーの操作をブロックしないという意味でもグローバルスピナーは多くの場合避けるべきUIとなり、ローカルなスピナーをSuspenseとともに散りばめるという形になっていくのではないでしょうか。

論点2: じゃあ、エラーハンドリングは?

React界隈に詳しい方は、こんなコードをみたことがあるのではないでしょうか。

<ErrorBoundary fallback={<ErrorMessage />}>
  <Suspense fallback={<Spinner />}>
    <Comments />
  </Suspense>
</ErrorBoundary>

これは、Commnents内でthrowされたエラーをErrorBoundaryでキャッチし、fallbackにエラー内容ををpropsとして詰めて表示させるといったものを意図したコードです。

実はReact Core Teamの一人であるbvaughnが作ったライブラリを使うと上記のようなコードは動きます。SuspenseはPromiseのthrow以外はキャッチしないために、エラーをthrowした場合はその上のErrorBoundaryまで到達してキャッチされるというわけです。

https://github.com/bvaughn/react-error-boundary

一見これはクリーンに見えるかもしれません。しかし、このコードが動くためにはCommentsの中でErrorをThrowする必要があります。これはCommentsコンポーネント側からみると、外部の実装に大きく依存することになってしまいます。また、フェッチャーライブラリがこの実装を想定していたとして、内部実装を意識して使うわけにもいかないことを考えると、あまり現実的ではない気もしてきます。(Suspenseも若干その側面があるのは否めませんが)

そのためかはわかりませんが、ReactのコアチームがSuspense for Datafetchingの文脈でErrorboundaryに言及するのを最近は見かけません。何らかの違ったメカニズムを考えている可能性はありますが、現状は今まで通りのハンドリングしてあげる必要がありそうです。今後の動向を注視したいと思います。

参考:

https://github.com/reactwg/react-18/discussions/81

論点3: エコシステムの追従

react-18のワーキンググループにReact 18対応されたライブラリをまとめるスレッドがあるので参考になるかもしれませんが、まだまだというところなのかなと思います。

https://github.com/reactwg/react-18/discussions/113

Suspenseに関しては、直近リリースされたReact18対応のReact Testing Library v13でSuspenseを利用したコンポーネントのテストを書いたところ、Suspenseにラップされたコンポーネントを単純にqueryしても動かず1tick遅らせる必要があったり、テストのcleanupがうまく動いていなかったりと怪しい挙動だらけでした(もしかしたら自分が使い方間違ってる説はありますが)

またSuspenseからは外れますが、useSyncExternalStoreの対応は各ライブラリやや大変そうに思います。例えば、urqlでは一時対応されましたがリグレッションが発覚しrevertされていますし、React外状態管理層をもったライブラリ(form系,fetch系など実はかなり多いです)が果たしてどこまで対応する必要があるのかという点はまだ曖昧に感じます。

参考:

https://github.com/reactwg/react-18/discussions/113#discussioncomment-1647638
https://github.com/FormidableLabs/urql/issues/2309

論点4: startTransitionとの関係

これは論点ではないかもしれないですが、分かりづらい側面があるので説明します。
シンプルに考えましょう。

初期データフェッチ:Suspenseを使ってloading中はfallbackを表示する
再フェッチ:startTransitionを使うと、再フェッチした際の再ローディング状態によってsuspenseがfallbackを表示するのを避け、新しいデータがやってくるまで古いデータを出しておいてくれます。

参考:

https://github.com/reactwg/react-18/discussions/94#discussioncomment-1406166

おわりに

また利用が進んでいけば新たな論点が出てくると思うので随時追記予定です。なにか気づいた方はぜひコメント等いただければ幸いです。