🌊

React 18.3のuseとSuspenseのSSR動作をNext.js 13の app/ Directory (beta) で確認する

2022/10/30に公開2

SuspenseとSSR動作に関して

React18.3以降の新機能useを使うと、throw promiseとデータの取得部分の処理が簡略化出来ます。そしてuseを使ったコンポーネントは非同期中の状態を受け取るためSuspenseとセットで使われることが想定されます。しかしNext.jsで使う場合、必ずしもセットにしなくても動作します。その場合は異なった挙動をするので、違いを確認するためNext.js 13の新機能app/ Directory (beta)を使って出力結果を検証します。ちなみにReact 18.2やNext.js 12以前のバージョンでも同様の動作をします。最新版で固めたのは、新機能が非同期に最適化されているため、コードの記述量が減るからです。

Next.jsの初期設定

  • インストールするパッケージ

TypeScriptや@types/nodeは自動で入ります。

yarn add react@next react-dom@next next  
yarn add -D @types/react
  • next.config.js
// @ts-check
/**
 * @type { import("next").NextConfig}
 */
const config = {
  reactStrictMode: true,
  experimental: {
    appDir: true
  },
};
module.exports = config;

ちなみにStreaming-SSRをするためにruntime: "experimental-edge"の設定は不要です。appフォルダの機能を使うと自動的にStreamingが有効になります。

サンプルソース

  • 実行環境 & ソースコードへのリンク

https://next-layouts-test.vercel.app/

  • src/app/page.tsx

動作確認用リンクです。完全にページ遷移する必要があるのでaタグでリンクを張っています。

const Page = () => {
  return (
    <>
      <div>
        <a href="https://github.com/SoraKumo001/next-layouts-test">Source code</a>
      </div>
      <hr />
      <div>
        <a href="/suspense">Suspense有り</a>
      </div>
      <div>
        <a href="/nosuspense">Suspense無し</a>
      </div>
    </>
  );
};
export default Page;
  • src/app/suspense/page.tsx

1秒のウエイトが入った状態で東京の天気予報を取得します。
Streamingが有効になっているので初期状態でLoadingが表示され、その後、天気予報に切り替わります。

import React, { Suspense, use } from "react";

export interface WeatherType {
  publishingOffice: string;
  reportDatetime: string;
  targetArea: string;
  headlineText: string;
  text: string;
}

const fetchWeather = (id: number): Promise<WeatherType> =>
  fetch(`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`)
    .then((r) => r.json())
    .then(
      //ウエイト追加
      (r) => new Promise((resolve) => setTimeout(() => resolve(r), 1000))
    );


const Weather = () => {
  //Reactの新機能useでデータを取り出す
  const weather = use(fetchWeather(130000));
  return (
    <div>
      <h1>{weather.targetArea}</h1>
      <div>
        {new Date(weather.reportDatetime).toLocaleString('ja-JP', {
          timeZone: 'JST',
          year: 'numeric',
          month: 'narrow',
          day: 'numeric',
          hour: 'numeric',
          minute: 'numeric',
        })}
      </div>
      <div>{weather.headlineText}</div>
      <pre>{weather.text}</pre>
    </div>
  );
};

const Page = () => {
  return (
    <Suspense fallback='Loading'>
      <Weather />
    </Suspense>
  );
};
export default Page;
  • src/app/nosuspense/page.tsx

1秒のウエイトが入った状態で東京の天気予報を取得します。
こちらはSuspenseを入れていません。
Suspenseが無い場合はStreamingが行われず、データが出そろうまで待機状態になります。

import React, { Suspense, use } from "react";

export interface WeatherType {
  publishingOffice: string;
  reportDatetime: string;
  targetArea: string;
  headlineText: string;
  text: string;
}

const fetchWeather = (id: number): Promise<WeatherType> =>
  fetch(`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`)
    .then((r) => r.json())
    .then(
      //ウエイト追加
      (r) => new Promise((resolve) => setTimeout(() => resolve(r), 1000))
    );


const Weather = () => {
  //Reactの新機能useでデータを取り出す
  const weather = use(fetchWeather(130000));
  return (
    <div>
      <h1>{weather.targetArea}</h1>
      <div>
        {new Date(weather.reportDatetime).toLocaleString('ja-JP', {
          timeZone: 'JST',
          year: 'numeric',
          month: 'narrow',
          day: 'numeric',
          hour: 'numeric',
          minute: 'numeric',
        })}
      </div>
      <div>{weather.headlineText}</div>
      <pre>{weather.text}</pre>
    </div>
  );
};

const Page = () => {
  return (
    <Weather />
  );
};
export default Page;

出力HTMLの違い

Suspense有り

Streaming-SSRが行われると、1つのコネクションで、読み込み中の表示から結果のレンダリングまでの内容が送信できます。追加分の再配置はJavaScriptが行います。そのため、JavaScriptを解釈できない環境にデータを送る場合に問題が生じます。SNSへのリンクの貼り付け時にOGPを送りたい場合などです。

  • JavaScriptを切った場合の表示結果

  • HTMLの内容

<div hidden id="S:0">以降は、JavaScriptが動作していないと表示されません。

<!DOCTYPE html>
<html>
    <head>
        <script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
    </head>
    <body>
        <div>
            <div>
                <!--$?-->
                <template id="B:0"></template>
                Loading
                <!--/$-->
            </div>
        </div>
        <script src="/_next/static/chunks/webpack.js" async=""></script>
        <script src="/_next/static/chunks/main-app.js" async=""></script>
        <script>
            (self.__next_f = self.__next_f || []).push([0])
        </script>
        <script>
            self.__next_f.push([1, "M1:{\"id\":\"./node_modules/next/dist/client/components/app-router.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\nM2:{\"id\":\"./node_modules/next/dist/client/components/layout-router.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\nM3:{\"id\":\"./node_modules/next/dist/client/components/render-from-template-context.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\nS4:\"react.suspense\"\n"])
        </script>
        <script>
            self.__next_f.push([1, "J0:[\"$\",\"@1\",null,{\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/suspense\",\"initialTree\":[\"\",{\"children\":[\"suspense\",{\"children\":[\"\",{}]}]},null,null,true],\"initialHead\":null,\"children\":[[],[],[\"$\",\"html\",null,{\"children\":[[\"$\",\"head\",null,{}],[\"$\",\"body\",null,{\"children\":[\"$\",\"@2\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"hasLoading\":false,\"template\":[\"$\",\"@3\",null,{}],\"notFound\":[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"-apple-system, BlinkMacSystemFont, Roboto, \\\"Segoe UI\\\", \\\"Fira Sans\\\", Avenir, \\\"Helvetica Neue\\\", \\\"Lucida Grande\\\", sans-serif\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}]}],[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"\\n            body { margin: 0; color: #000; background: #fff; }\\n            .next-error-h1 {\\n              border-right: 1px solid rgba(0, 0, 0, .3);\\n            }\\n\\n            @media (prefers-color-scheme: dark) {\\n              body { color: #fff; background: #000; }\\n              .next-error-h1 {\\n                border-right: 1px solid rgba(255, 255, 255, .3);\\n              }\\n            }\\n          \"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":0,\"marginRight\":\"20px\",\"padding\":\"0 23px 0 0\",\"fontSize\":\"24px\",\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\",\"textAlign\":\"left\",\"lineHeight\":\"49px\",\"height\":\"49px\",\"verticalAlign\":\"middle\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":\"14px\",\"fontWeight\":\"normal\",\"lineHeight\":\"49px\",\"margin\":0,\"padding\":0},\"children\":\"This page could not be found.\"}]}]]}]]}],\"childProp\":{\"current\":[\"$\",\"@2\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"suspense\",\"children\"],\"hasLoading\":false,\"template\":[\"$\",\"@3\",null,{}],\"childProp\":{\"current\":[[],[],[\"$\",\"$4\",null,{\"fallback\":\"Loading\",\"children\":\"@5\"}]],\"segment\":\"\"},\"rootLayoutIncluded\":true}],\"segment\":\"suspense\"},\"rootLayoutIncluded\":true}]}]]}]]}]\n"])
        </script>
        <script>
            self.__next_f.push([1, "J5:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h1\",null,{\"children\":\"東京都\"}],[\"$\",\"div\",null,{\"children\":\"2022年10月30日 10:36\"}],[\"$\",\"div\",null,{\"children\":\"\"}],[\"$\",\"pre\",null,{\"children\":\" 本州付近は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。\\n\\n 東京地方は、おおむね晴れとなっています。\\n\\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。\\n\\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。\\n\\n【関東甲信地方】\\n 関東甲信地方は、晴れや曇りとなっています。\\n\\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\\n\\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\\n\\n 関東地方と伊豆諸島の海上では、うねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。\"}]]}]\n"])
        </script>
        <div hidden id="S:0">
            <div>
                <h1>東京都</h1>
                <div>2022年10月30日 10:36</div>
                <div></div>
                <pre>本州付近は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。

 東京地方は、おおむね晴れとなっています。

 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。

 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。

【関東甲信地方】
 関東甲信地方は、晴れや曇りとなっています。

 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。

 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。

 関東地方と伊豆諸島の海上では、うねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。</pre>
            </div>
        </div>
        <script>
            $RC = function(b, c, e) {
                c = document.getElementById(c);
                c.parentNode.removeChild(c);
                var a = document.getElementById(b);
                if (a) {
                    b = a.previousSibling;
                    if (e)
                        b.data = "$!",
                        a.setAttribute("data-dgst", e);
                    else {
                        e = b.parentNode;
                        a = b.nextSibling;
                        var f = 0;
                        do {
                            if (a && 8 === a.nodeType) {
                                var d = a.data;
                                if ("/$" === d)
                                    if (0 === f)
                                        break;
                                    else
                                        f--;
                                else
                                    "$" !== d && "$?" !== d && "$!" !== d || f++
                            }
                            d = a.nextSibling;
                            e.removeChild(a);
                            a = d
                        } while (a);
                        for (; c.firstChild; )
                            e.insertBefore(c.firstChild, a);
                        b.data = "$"
                    }
                    b._reactRetry && b._reactRetry()
                }
            }
            ;
            ;$RC("B:0", "S:0")
        </script>
    </body>
</html>

Suspense無し

Suspenseを使わなかった場合は、データが出そろった状態のHTMLが出力されます。もう少し正確に言うと、useやthrowで未解決のpromiseが全て解決されるまで待機状態になります。どこかしらにSuspenseで囲われていないthrowが一箇所でも投げられていれば、そこで一時停止となります。

  • JavaScriptを切った場合の表示結果

  • HTMLの内容
<!DOCTYPE html>
<html>
    <head>
        <script src="/_next/static/chunks/polyfills.js" nomodule=""></script>
    </head>
    <body>
        <div>
            <div>
                <div>
                    <h1>東京都</h1>
                    <div>2022年10月30日 10:36</div>
                    <div></div>
                    <pre>本州付近は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。

 東京地方は、おおむね晴れとなっています。

 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。

 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。

【関東甲信地方】
 関東甲信地方は、晴れや曇りとなっています。

 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。

 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。

 関東地方と伊豆諸島の海上では、うねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。</pre>
                </div>
            </div>
        </div>
        <script src="/_next/static/chunks/webpack.js" async=""></script>
        <script src="/_next/static/chunks/main-app.js" async=""></script>
    </body>
</html>
<script>
    (self.__next_f = self.__next_f || []).push([0])
</script>
<script>
    self.__next_f.push([1, "M1:{\"id\":\"./node_modules/next/dist/client/components/app-router.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\nM2:{\"id\":\"./node_modules/next/dist/client/components/layout-router.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\nM3:{\"id\":\"./node_modules/next/dist/client/components/render-from-template-context.js\",\"name\":\"default\",\"chunks\":[\"app-internals:app-internals\"],\"async\":false}\n"])
</script>
<script>
    self.__next_f.push([1, "J0:[\"$\",\"@1\",null,{\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/nosuspense\",\"initialTree\":[\"\",{\"children\":[\"nosuspense\",{\"children\":[\"\",{}]}]},null,null,true],\"initialHead\":null,\"children\":[[],[],[\"$\",\"html\",null,{\"children\":[[\"$\",\"head\",null,{}],[\"$\",\"body\",null,{\"children\":[\"$\",\"@2\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"hasLoading\":false,\"template\":[\"$\",\"@3\",null,{}],\"notFound\":[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"-apple-system, BlinkMacSystemFont, Roboto, \\\"Segoe UI\\\", \\\"Fira Sans\\\", Avenir, \\\"Helvetica Neue\\\", \\\"Lucida Grande\\\", sans-serif\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}]}],[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"\\n            body { margin: 0; color: #000; background: #fff; }\\n            .next-error-h1 {\\n              border-right: 1px solid rgba(0, 0, 0, .3);\\n            }\\n\\n            @media (prefers-color-scheme: dark) {\\n              body { color: #fff; background: #000; }\\n              .next-error-h1 {\\n                border-right: 1px solid rgba(255, 255, 255, .3);\\n              }\\n            }\\n          \"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":0,\"marginRight\":\"20px\",\"padding\":\"0 23px 0 0\",\"fontSize\":\"24px\",\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\",\"textAlign\":\"left\",\"lineHeight\":\"49px\",\"height\":\"49px\",\"verticalAlign\":\"middle\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":\"14px\",\"fontWeight\":\"normal\",\"lineHeight\":\"49px\",\"margin\":0,\"padding\":0},\"children\":\"This page could not be found.\"}]}]]}]]}],\"childProp\":{\"current\":[\"$\",\"@2\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"nosuspense\",\"children\"],\"hasLoading\":false,\"template\":[\"$\",\"@3\",null,{}],\"childProp\":{\"current\":[[],[],\"@4\"],\"segment\":\"\"},\"rootLayoutIncluded\":true}],\"segment\":\"nosuspense\"},\"rootLayoutIncluded\":true}]}]]}]]}]\n"])
</script>
<script>
    self.__next_f.push([1, "J4:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h1\",null,{\"children\":\"東京都\"}],[\"$\",\"div\",null,{\"children\":\"2022年10月30日 10:36\"}],[\"$\",\"div\",null,{\"children\":\"\"}],[\"$\",\"pre\",null,{\"children\":\" 本州付近は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。\\n\\n 東京地方は、おおむね晴れとなっています。\\n\\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。\\n\\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。\\n\\n【関東甲信地方】\\n 関東甲信地方は、晴れや曇りとなっています。\\n\\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\\n\\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\\n\\n 関東地方と伊豆諸島の海上では、うねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。\"}]]}]\n"])
</script>

まとめ

今回はNext.jsのSuspenseによる挙動の違いを確認しました。その際にNext.jsの13で加わったapp/ Directory (beta)を使用しましたが、従来のpagesコンポーネント場合も同じ挙動をします。ただ、ServerComponetsを使用すると、クライアント側での諸々の処理を書かなくて良いので解説用に利用しました。

app/ Directory (beta)では、今回のようにStreaming-SSRが簡単に行えます。ただし注意点はServerComponentsが前提となっていることです。ServerComponentsはステートを作れず、自由な状態変化を使うことが出来ません。かなり使い回しが難しくなります。サンプルプログラムの範囲を超えてまともに実装していくと、かなり苦労することになります。

ちなみに、app/ Directory (beta)やServerComponentsを利用せず、use+Suspenseを使っていても完成版のHTMLを出力する記事も書きました。

https://next-blog.croud.jp/contents/zRYIDSBtrEwtfIsg1nCS

ServerComponentsを使っていないので、従来通りステートも使えます。面倒くさい制約は特にありません。

GitHubで編集を提案

Discussion

aiktbaiktb

Hi, I'm trying to use the use hook in TSX, which is currently located in react@canary (an alias of react@next), and I've added the following snippet to tsconfig.json for Typescript to have type hints.

"compilerOptions": {
    "types": ["react/canary"],
  }

But I don't see any description of these in your post, how did you do it?

空雲空雲

Next.js declares the type of use

  • node_modules/next/types/index.d.ts
declare module 'react' {
  // <html amp=""> support
  interface HtmlHTMLAttributes<T> extends React.HTMLAttributes<T> {
    amp?: string
  }

  // <link nonce=""> support
  interface LinkHTMLAttributes<T> extends HTMLAttributes<T> {
    nonce?: string
  }

  // TODO-APP: check if this is the right type.
  function use<T>(promise: Promise<T> | React.Context<T>): T
  function cache<T extends Function>(fn: T): T
}