🤠

Next.js 13 の React Server Components(RSC) とデータフェッチ

2022/11/01に公開約9,600字

Next.js 13

Next.js 13では試験的(beta)な試みとして、appフォルダに置いたReactコンポーネントをデフォルトでServer Componentsとして扱います。

All components inside the app directory are React Server Components by default, including special files and colocated components. This allows you to automatically adopt Server Components with no extra work, and achieve great performance out of the box.
https://beta.nextjs.org/docs/rendering/server-and-client-components

RSCではReact関数をasyncと指定して、関数内部でawaitを使用することが可能です。

RSC
export default async function MySrvComponent() {
    const ret = await getData(); // 非同期I/O呼び出し
    return <div>ret</div>
}

これまではgetStaticPropsget()ServerSideProps()で関数コンポーネントの外でデータフェッチをしていたわけですがそれは廃止になります。
getData()にはデータベースやAPIサーバへの非同期I/Oを実装し、async関数からPromiseを返すことで処理を一旦中断します。
Suspenseでasyncコンポーネントをラップすることで、Promiseがresolveされるまではとり急ぎfallbackの内容をレスポンスでブラウザに返してしまいます。その後、resolveしたらストリーミングでその部分のHTMLデータ(特殊なHTMLフォーマット)を追加で返します。

サスペンス対応
<Suspense fallback={<img src='loading.gif'}> // グルグルローディング画像
    <MySrvComponent postId={p.id}/> // async関数。Promiseが返る
</Suspense>

RSC自体は以前からあるものです。こちらの記事が詳しいです。

https://zenn.dev/g4rds/articles/287c53498d17a1

I/Oバウンドとストリーミング

Node.jsの特徴はI/Oバウンドなサービスをシングルスレッドで高速回転で処理することにあります。つまり1つのリクエストには何かしらのI/O待ちがありそれらを全て待つ無駄な時間があるわけです。CPUバウンドであればNode.jsを使用するべきではないでしょう。
例えば、このZennのブログサイトを例にしましょう。ページの下部を見るとおすすめ記事(Read Next)がありますね。この部分は機械学習サーバやサードパーティのAPI呼び出しで500msかかると妄想しましょう。記事本文自体は爆速で10msとしましょうか。
この場合、SSRで全部レスポンスが返るまで待ってからHTML全体をブラウザに返すと、おすすめ記事に足を引っ張られファーストバイト(TTFB)が500ms以上もかかってしまいます。
そうではなく、記事本文を先に返してしまい(TTFB=10ms)、おすすめ記事の部分はストリーミングで後から追加で返せば素敵なわけです。これを全てサーバサイドでやってやろうじゃないかというわけです。

With the app directory, components can be incrementally streamed in. This reduces both the Time To First Byte (TTFB) and First Contentful Paint (FCP) for displaying UI from the server.

https://beta.nextjs.org/docs/data-fetching/streaming-and-suspense

サンプルアプリ

こんな感じのを作りました。記事一覧が爆速で表示されます。記事に付随するコメント(2つ)の部分がグルグルで遅延ロード(5秒前後の乱数)で表示されます。

記事一覧はJSONPlaceholderを利用しました。サーバ側(Next.js)からfetchで取得しています。Webブラウザからではありません。トグルボタンを押すとコメント部分を表示・非表示に切り替えることができます。四角い枠はReact Dev ToolのHighlight updates when components renderをオンしたためです。

コードの説明

次の3つのReactコンポーネントから構成されます。

  1. 親 page.tsx (Serverコンポーネント) 記事一覧
  2. 子 switch.tsx (Clientコンポーネント) トグルスイッチ
  3. 孫 comments.tsx (Serverコンポーネント) コメント一覧

1 記事一覧

記事一覧を表示します。サーバ・コンポーネントになります。fetchを使用して外部API(JSONPlaceholder)を呼び出します。このガラの部分は爆速でWebブラウザにレスポンスを返します。コメント部分をSuspenseで囲むことで遅延ロードでグルグル画像を表示させます。

page.tsx
import Comments from "./comments";
import Switch from "./switch";
import {Suspense} from "react";

// 記事一覧を返す (コメントは含まない)
async function getPosts() {
    const url1 = 'https://jsonplaceholder.typicode.com/posts'
    const res = await fetch(url1, {cache: 'no-store'})
    return res.json();
}

// ページ全体を表示するReact関数コンポーネント(async)
export default async function Page() {
    const posts = await getPosts();
    console.log('in posts')

    return (
        <ul> {
	posts.map((p) => { // 1 記事一覧
	    return (
		<li key={p.id}>
		    <h3>記事ID {p.id} : {p.title}</h3>
		    <Switch> // 2 トグルスイッチ
			<Suspense fallback={<img src='loading.gif'}/>
			    <Comments postId={p.id}/> // 3 コメント一覧
			</Suspense>
		    </Switch>
		</li>
	    )
	})
        } </ul>
    )
}

デフォルトだとキャッシュが有効になりビルド時にページを生成してしまうので、{cache: 'no-store'}を指定してキャッシュを無効にしてリクエストの度にレンダリングするようにしています。{ next: { revalidate: 10 } }にするとインクリメンタルになります。

2  トグルスイッチ

トグルボタン(のつもり)を表示します。クライアント・コンポーネントです。
useStateonClickイベントハンドラーを使用するため、クライアントコンポーネントにする必要があります。デモ動画で四角い枠がフラッシュ(React Dev Tool)していることから再レンダリングされているのが分かります。

switch.tsx
"use client"; // クライアントコンポーネントにします。

import {useState} from "react";

// サーバコンポーネントをプロパティで渡します。
export default function Switch({children}) {
    const [flag, setFlag] = useState(true)

    const handler = () => {
        setFlag((prevState) => !prevState) // トグルスイッチ
    }

    return (
        <>
            <button onClick={() => handler()}>トグルスイッチ {flag ? "ON" : "OFF"}</button>
            {flag ? children : <div>...</div>}
        </>
    )
}

3 コメント一覧

記事に紐づくコメント一覧(2件)を表示します。サーバコンポーネントです。
グルグル画像を表示したいので、乱数を使用してわざと遅延を入れてあります。この部分は先ほど例に挙げたおすすめ記事などのような処理が重たい非同期I/O呼び出しになります。

comments.tsx
async function getComments(postId: string) {
    // ストリーミングが分かりやすいようにランダムにスリープ(遅延)させます。
    await myRandomSleep() // よくあるsetTimeoutスリープ。寝る時間は乱数で決まる。
    // 指定した記事のコメント一覧を取得します。
    const url1 = `https://jsonplaceholder.typicode.com/comments?postId=${postId}`
    const res = await fetch(url1, {cache: 'no-store'})
    return res.json();
}

export default async function Comments(postId) {
    const comments = await getComments(postId);
    console.log(`get コメント no.${postId}`)
    return (
        <ul>
            {
                comments.map((c) => { // コメント一覧を表示
                    return (
                        <li key={c.id}>
                            <div>[{c.email}] {c.name}</div>
                        </li>
                    )
                })
            }
        </ul>
    )
}

実行してみる

Node.js(Next.js)のコンソールログです。サーバ側でフェッチを実行しているのが確認できます。

Chrome Dev Toolでソースコードを確認してみましょう。Server Componentsは特殊なHTMLフォーマットでアルファベットのキーとJSONの値で構成されます。完成形のHTMLを返すSSG/SSRとは違います。

cURLで直接叩いてみるとそれがよりはっきりと分かります。loadingのところで一旦出力が止まりますが、その後コメント一覧部分がどどっと出力されます。cURLですのでJavaScriptを実行している訳ではありません。サーバからのデータを垂れ流してるだけです。

クラ・コンからサバ・コンをインポートする場合の注意点

クラ・コンとサバ・コンはごちゃ混ぜに使用できますが、クラ・コン(親)、サバ・コン(子)のパターンには制約があります。デモプログラムでトグルスイッチ(親)コメント一覧(子)がその関係になっています。
ご承知の通り、Reactでは親コンポーネントが再レンダリングされると、芋づる式に子コンポーネントも再レンダリングされます。(memo化しない限り)
従って以下のようにレンダリング処理中にサバ・コンを呼び出すことはできません。

クラ->サバ
import Comments from './comments.tsx'; // サバ・コンをインポートできない

export default function Switch() {
    return <Comments>
}

回避策は、ダイアログなどで使用するコンポジットパターンを使用します。プロパティでサバ・コンを渡します。

クラ->サバ
export default function Switch(props) {
    return {props.children}
}

<Switch>
    <Comments/>
</Swithc>

静的レンダリング vs 動的レンダリング

SSGとSSRの違いと似ていますが、RSCにもビルド時にレンダリングする方式(static)とリクエスト時にレンダリングする方式(dynamic)の2パターンがあります。デフォでは静的レンダリングになります。キャッシュを無効化したり、動的関数cookies()、headers()を使用したRSCは動的レンダリングに自動的になります。また、開発環境では常に動的レンダリングになります。

https://beta.nextjs.org/docs/rendering/static-and-dynamic-rendering#static-rendering-default

Know Issues

まだベータ機能なのでGitHubにたくさんIssueが上がっています。最新版をチェックしてください。

TypeScriptエラー

asyncのRSC(Commentsなど)はTypeScriptのエラーが発生します。回避策は下記の関数asyncComponentでラップするとエラーは消えます。

typescript-error
function asyncComponent<T, R>(fn: (arg: T) => Promise<R>): (arg: T) => R {
    return fn as (arg: T) => R;
}

const Comments = asyncComponent(async () => {...})

fetchエラー

JS界隈で有名なYouTuberのTheoどハマりして激おこしていましたが、fetchのレスポンスのサイズが15kb以上あるとスタックするようです。axiosでは問題ないようです。

https://github.com/vercel/next.js/issues/41853

クライアント側からのfetch

今のところSWRの使用が推奨されています。Tan Stackなどでも問題ないと思います。

fetch is currently not supported in Client Components and may trigger multiple re-renders. For now, if you need to fetch data in a Client Component, we recommend using a third-party library such as SWR.

SWR-axios
"use client";

import useSWR from 'swr'
import axios from "axios";

const fetcher = (url: string) => axios.get(url).then(res => res.data)

export default function Page() {
    const {data, error} = useSWR('/api/hello', fetcher)

Mutation

Mutationに関しては現在RFCを作成中で、まだこれからといったところ。照会系のページは良いのですが、更新処理が入るところは難しそうですね。デモの場合、コメントの追加、更新はどうやってやるのか悩ましいところです。

The Next.js team is working on a new RFC for mutating data in Next.js. This RFC has not been published yet. For now, we recommend the following pattern:

https://beta.nextjs.org/docs/data-fetching/mutating

参考ソース

アップルのイベントのような雰囲気でオシャレです。最近のテックのイベントはアップルを真似たものが多いですね。

https://nextjs.org/conf

https://github.com/reactjs/server-components-demo

https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#example-use-in-client-components-and-hooks

https://www.plasmic.app/blog/how-react-server-components-work

Discussion

ログインするとコメントできます