🎃

SWR と Suspense の問題

2024/04/10に公開

はじめに

Suspense を使用したデータフェッチングが流行っているように思います。
私は愛用の SWR で Suspense を使おうと調べてみたところ、ドキュメントに次のような文章を発見しました。

React はまだサスペンスをデータ取得フレームワークである SWR などで使うことを 推奨していません。 これらの API は将来的に私たちの調査により変更される可能性があります。

詳しく調査して React の現在の仕様に関わる面白いことが分かったので記事にして共有します。

ちなみに SWR と Suspense の問題と言っていますが、これは Suspense を使用する全データフェッチングライブラリ(例:Tanstack Query)で生じる問題らしいです。
ですので私が調べたことが間違っていなければ、正しいタイトルは 「SWR と Suspense の問題」 ではなく 「Suspense の問題」 です。
ですがSWR 以外のライブラリで検証する体力がなかったため、この記事では焦点を SWR に絞ります。

基本的に以下の技術構成で話を進めます。

  • Next.js v14.1.4
    • Pages Router(App Router でも発生)
  • SWR v2.2.5
  • React v18

なぜ React は SWR のようなライブラリで Suspense を使うのをおすすめしていないのか

参考になるソースは以下の 2 つです。

  • React チームの Suspense に対する意見
  • SWR のメンテナの意見

React チームの Suspense に対する言及

React チームの v18 の Suspense についての記事では以下のように書かれています。

Ad hoc data fetching with Suspense is technically possible, but still not recommended as a general strategy.
Suspense でその場しのぎなデータフェッチングは技術的に可能だが、一般的な戦略としてはまだおすすめされない。

Suspense-enabled data fetching without the use of an opinionated framework is not yet supported. The requirements for implementing a Suspense-enabled data source are unstable and undocumented. An official API for integrating data sources with Suspense will be released in a future version of React.
フレームワークを使用しない Suspense ベースのデータフェッチングはまだサポートされていません。サスペンス対応のデータソースを実装するための要件は不安定で、ドキュメント化されていません。データソースをサスペンスと統合するための公式APIは、将来のReactのバージョンでリリースされる予定です。
https://react.dev/reference/react-dom/server/renderToPipeableStream#usage

このことから Suspense を使用するデータフェッチングはライブラリレベルではサポートされてないことがわかります。

SWR のメンテナの意見

SWR (Next.js)のメンテナの shuding さんはSWR を React v18 対応して Suspense を導入しようという Issue において次の 2 つの発言しています。

I personally still don't recommend using it in production:
個人的にはプロダクションで使用することはまだおすすめしません。

I didn't mean that data fetching libraries should not use Suspense. What I'm thinking is Suspense based data fetching is almost impossible to implement correctly in the library level as of today, it should be done to the framework level.
私はデータフェッチングライブラリは Suspense を使うべきではないと言うつもりはありませんでした。私が思うに、Suspense ベースのデータフェッチングをライブラリレベルで正しく実装することは現状はほとんど不可能で、それはフレームワークレベルで行われるべきです。

こう書いてあることから、Suspense を使用するデータフェッチングをライブラリレベルで実装することはほとんど不可能で、問題があるためおすすめできないことがわかります。

ちなみに shuding さんのこのコメント曰く、この問題は SWR だけの問題ではなく、すべての Suspense ベースのデータフェッチングライブラリで生じる問題だそうです。

以上 2 つから、React と SWR のそれぞれの開発者はデータフェッチングライブラリが Suspense を使うことを推奨していないことが分かります。

SWR と Suspense の問題

Suspense の使用が開発者によって推奨されていないことは分かりました。
ではなぜ推奨されていないのでしょうか。
SWR と Suspense の問題点について具体的に見ていきます。

同じく shuding さんによって SWR と Suspense の問題が語られています。
その問題点というのが以下の 2 つです。

順に説明します。

SSR 時にハイドレーションのミスマッチの発生について

なぜ SSR で SWR と Suspense を使うとハイドレーションのミスマッチが起こるのかというと、Suspense 内部のコンポーネントはサーバーサイドでも行われるからです。

そうなると

  1. サーバーサイド
  2. クライアントサイド

の両方でデータフェッチが行われるのでハイドレーションのミスマッチが起こる可能性があります。

どういうことか説明します。
例えばミリ秒単位の時刻情報をフェッチする API があるとして、これをサーバーサイドとクライアントサイドの両方で叩くとします。
そうなると時刻のズレの影響で両サイドの結果が同じになるとは限りません。
その結果両サイドがミスマッチするためハイドレーションエラーで生じる、ということです。

察しのよい方ならお分かりかもしれませんが、この問題は create-react-app のような SSR をしないライブラリだったり、API で取得するデータがほぼ静的なら起こり得ません。

ちなみに「なぜ Suspense 内部のコンポーネントがサーバーサイドで実行されるか」を調べるために SWR のソースコードを見ましたが分かりませんでした。
筆者に Streaming with Suspense の知識とサーバー環境での React hooks の知識があれば分かったかもしれません。

この問題は現在とある方法で解消されています。
後述する解決策で取り上げます。

SWR の Suspense のウォーターフォールと条件付きリクエスト問題が未解決について

筆者の環境では再現できなかったため、ただの紹介になります。
(そもそもこの問題の解決策が語られているだけで、その問題が何なのか、またその問題がどうすれば発生するかなどは書いてありませんでした)

ウォーターフォールの問題とは依存関係にないリクエストが並列ではなく直列に処理されることなはずです。(明確な言及見つからず)
条件付きリクエストとは下のように特定の条件が満たされたときにリクエストを送信することです。

// 条件付きリクエスト
const { data } = useSWR(shouldFetch ? '/api/data' : null, fetcher)

SWR は依存的なリクエストを可能な限り並列に、かつ賢く行います。
もし次のような 3 つのリクエストがあったとしても、SWR は賢いので A と B を並列に処理し、A と B の両方が完了次第 C を処理します。

  • リクエスト A
  • リクエスト B
  • A, B 両方の結果に依存するリクエスト C

ですがどうやら Suspense とともに使用すると不具合があるらしいです。
「らしい」と言っているのは、筆者の環境では再現できなかったからです。

再現環境を code sandbox に置いておきます。

code sandbox の内容

以下のような非依存的なリクエストを 2 つ、そしてどちらかに依存的なリクエスト 2 つの計 4 つのリクエストを用意しました。

  • リクエスト A
  • リクエスト B
  • A に依存的なリクエスト C
  • B に依存的なリクエスト D

これら 4 つのリクエストを用意し fetch が走ったタイミングで console に記録するようにしたところ、 suspense がある場合とない場合の両方で同じ賢い振る舞いしました。

これ以上筆者には分からなかったため有識者の方がいらっしゃったら教えていただけると幸いです。

一応この問題を解決するために様々な試みが紹介されています。

この問題の解決のための試み

面白いのを抜粋します。

https://github.com/vercel/swr/pull/168
https://gist.github.com/shuding/6ef6a85c4c8ee57d9926e705adef88e3

// 1個目
// 関数の中に hook があるのは奇妙だから却下らしい
function App () {
  const [user, movies] = suspenseGuard(()=> {
    const { data: user } = useSWR('/api/user')      // not Suspense-based
    const { data: movies } = useSWR('/api/movies')  // not Suspense-based
    return [user, movies]
  })

  return (
    <div>
      Hi {user.name}, we have {movies.length} movies on the list.
    </div>
  )
}

// 2個目
// React でこんな書き方できるんだ
// インターフェースが良くないから微妙らしい
function App () {
  useSWRSuspenseStart()
  const { data: user } = useSWR('/api/user')
  const { data: movies } = useSWR('/api/movies')
  useSWRSuspenseEnd()

  return (
    <div>
      Hi {user.name}, we have {movies.length} movies on the list.
    </div>
  )
}

解決策

ウォーターフォールの問題は確認できなかったため、現状の問題はハイドレーションのミスマッチです。

ついに解決策を見ていきましょう。
解決策は公式が 1 つ、筆者が考えたものが 2 つで合計 3 つあります。

公式による解決策

まずそもそも Suspense が SSR で実行されることは避けられません。
そのため公式が打ち出したハイドレーションを起こさないためのの解決策は 「クライアントサイドとサーバーサイドの初期データを統一して、初期表示を無理やり同じにしてやればいい」というものです。

それはuseSWR のオプションにある fallbackData を指定すれば可能です。
fallbackData は 初期データと考えるとよいと思います。

const dummy = [{ id: 1, title: "ダミー" }];

const { data } = useSWR(
  "https://api.github.com/repos/vercel/swr/issues",
  fetcher,
  { suspense: true, fallbackData: dummy }
);

code sandbox 置いておくので試してみてください。

この策の懸念点

正直私はこの方法がとても気に入りません。
なぜなら Suspense の fallback が使用されないからです。

code sandbox を見ると、ローディング時に<Suspense fallback={"loading..."}>の「loading...」が表示されず、その代わりに fallbackData に指定した「ダミー」という文字列が表示されます。

正直 Suspense を使用したい理由のほとんどが fallback だと思うので、これが使えないのは非常に痛いです。
そのため筆者はこの方法が気に入りません。

ちなみにこれは Dan 先生が提示した策です。

fallback が使用できないのは致命的だと思うので他の解決策を探します。

他の解決策

Suspense を使用したときに生じる問題点はすべて SSR によって生じるものでした。
なので基本方針は Suspense を含むコンポーネントを SSR させないです。
Next.js の pre-rendering も SSR に含まれることに注意してください。

具体的には以下の 2 つの方法があります。

  • pre-rendering する HTML の中身を空にする
  • lazy, next/dynamic を使用

pre-rendering する HTML の中身を空にする

よくある完全に SSR を避けるためのやり方です。

export default function Example() {
  const [isClient, setIsClient] = useState(false);
  useEffect(() => {
    setIsClient(true);
  }, []);
  return (
    isClient && (
      <Suspense fallback={<Loading />}>
        <Component />
      </Suspense>
    )
  );
}

こうすれば Suspense コンポーネントが SSR されることはありません。
これの問題点は pre-rendering で生成される HTML にfallback<Loading /> が含まれないということです。
少し工夫すれば含めることもできますが、そこまでして Suspense を使用したい理由は思いつきません。
Suspense を使用しないほうがより簡潔に書けると思います。

lazy, next/dynamic を使用

React には lazy という遅延ロードのための API があります。(Next.js にはこの lazy を利用した next/dynamic というものがあります)
昔はコード分割のために使われていましたが、最近では見ないような気がします。

next/dynamic は次のように使用します。
{ssr: false} オプションをつけることで SSR されなくなるため、 Component.jsx ファイルのコンポーネントが Suspense とともに SWR を使用していても、問題なく使用できます。

個人の感想ですが、筆者的にはこちらのほうが semantic だと思うのでおすすめです。

import dynamic from "next/dynamic"

const AvoidSSRComponent = dynamic(
  () => import('./Component'), 
  {ssr: false}
)

export default function Example() {
  return (
    <AvoidSSRComponent/>
  )
}

まとめ

  • SWR の Suspense は experimental
  • React, SWR の開発者はデータフェッチングに Suspense を非推奨
  • 現在の React の API 的にデータフェッチングライブラリで Suspense を取り扱うのはほぼ不可能
  • SWR と Suspense を使用するとハイドレーションのミスマッチが発生
  • Suspense を SSR させなければ万事解決

筆者的には Suspense オプションは使わないほうが無難だと思います。

あとがき

Suspense を使用するデータフェッチングをライブラリレベルで実装するのはほぼ不可能だという文章を引用しました。
引用するだけでなぜ不可能なのか語っていなかったため、それを簡単に解説します。

https://github.com/brillout/rfcs/blob/main/text/0000-inject-to-stream.md

この RFC は、ライブラリレベルで SSR stream を使用できるようにする React API の提案 です。
SSR stream というのは端的に言えばサーバーからクライアントにデータを非同期的、かつ連続的に送る仕組みです。

もともとサーバーサイドでの Suspense はデータをフェッチすると <script> タグにそのデータを含め、その<script> タグをクライアントに送信します。
ただクライアントにデータを送るための方法である renderToPipeableStream API がライブラリレベルでは使用できません。
そのためサーバーでデータフェッチングをしてもクライアントに送る手立てがないのですから、SSR で Suspense を使用することは不可能でした。

そしてこの現状を打破すべく、injectToStream という API と、 useStreamという新しいフックが提案されました。

これらを使用するとライブラリでも SSR stream (<script>を連続的にクライアントに送信する Flow) を使用することができるので、ライブラリがサーバーで取得したデータをクライアントに送信することができます。

// New `useStream()` hook
import { useStream } from 'react'

function SomeComponent() {
  const stream = useStream()
  if (stream === null) {
    // No stream available. (Client-side, or when there isn't any SSR stream at all.)
    // ...
  }
  const { injectToStream } = stream
  injectToStream('<script type="application/json">[{"some":"data"}]</script>')
  // ...
}

この API が実装されたら上であげた SWR の問題もなくなると思われます。
(まぁそのころに SWR があるかどうかは怪しいのですが)

React の進化も目まぐるしいですね。

Suspense も昔はコード分割のためだけのものだったのが、今やすべての非同期処理(コードの取得、データフェッチング、画像の取得)を扱うことにまで及んでいるのですから、今後とも進化するものと思われます。

もし間違いがあればコメントでご指摘ください。
可能な限り迅速に反映します。

参考

https://github.com/vercel/swr/issues/5
https://github.com/vercel/swr/issues/1832
https://github.com/vercel/swr/issues/1898
https://github.com/vercel/swr/issues/1906
https://github.com/vercel/swr/pull/1931
https://github.com/reactjs/rfcs/pull/219
https://gist.github.com/shuding/6ef6a85c4c8ee57d9926e705adef88e3
https://swr.vercel.app/docs/with-nextjs
https://react.dev/blog/2022/03/29/react-v18#suspense-in-data-frameworks
https://17.reactjs.org/blog/2019/11/06/building-great-user-experiences-with-concurrent-mode-and-suspense.html
https://zenn.dev/luvmini511/articles/71f65df05716ca

GitHubで編集を提案

Discussion