極限まで簡単に非同期データのSSRを実装する
極限まで簡単に SSR を実装する流れ
不要な既存機能
今回の内容を実装するに当たってgetServerSideProps、getInitialProps、React Server Componentsは必要ありません。特殊なものを仕込んだりはしないのでnext.config.jsの設定も不要です。_app.tsx に何か書く必要もありません。
非同期データがコンポーネント上で簡単に SSR で出力可能
非同期データを扱う最小限のサンプルコードです。コンポーネント上で非同期の'Hello world!'を返していますが、きちんと初期レンダリングで HTML 上に出力されています。もちろん fetch で取得したデータも利用出来ますが、そちらは後ほど紹介します。
src/pages/simple.tsx
非同期データが必要なコンポーネントを<SSRProvider>で囲み、データが必要なところでuseSSR経由で 非同期処理(fetch 等) を呼び出します。React Server Componentsのようなステートが持てないというような制約はありません。出力したコンポーネントはクライアントで自由に動かせます。
1import { SSRProvider, useSSR } from "next-ssr";23const Test = () => {4 const { data } = useSSR(async () => "Hello world!");5 return <div>{data}</div>;6};78const Page = () => {9 return (10 <SSRProvider>11 <Test />12 </SSRProvider>13 );14};15export default Page;
以下が出力後の HTML データです。<div>Hello world!</div>が入っているのが確認出来ます。
1<!DOCTYPE html>2<html>3 <head>4 <style data-next-hide-fouc="true">5 body {6 display: none;7 }8 </style>9 <noscript data-next-hide-fouc="true">10 <style>11 body {12 display: block;13 }14 </style>15 </noscript>16 <meta charset="utf-8" />17 <meta name="viewport" content="width=device-width" />18 <meta name="next-head-count" content="2" />19 <noscript data-n-css=""></noscript>20 <script21 defer=""22 nomodule=""23 src="/_next/static/chunks/polyfills.js?ts=1677395377171"24 ></script>25 <script26 src="/_next/static/chunks/webpack.js?ts=1677395377171"27 defer=""28 ></script>29 <script30 src="/_next/static/chunks/main.js?ts=1677395377171"31 defer=""32 ></script>33 <script34 src="/_next/static/chunks/pages/_app.js?ts=1677395377171"35 defer=""36 ></script>37 <script38 src="/_next/static/chunks/pages/simple.js?ts=1677395377171"39 defer=""40 ></script>41 <script42 src="/_next/static/development/_buildManifest.js?ts=1677395377171"43 defer=""44 ></script>45 <script46 src="/_next/static/development/_ssgManifest.js?ts=1677395377171"47 defer=""48 ></script>49 <noscript id="__next_css__DO_NOT_USE__"></noscript>50 </head>51 <body>52 <div id="__next">53 <div>Hello world!</div>54 <script id="__NEXT_DATA_PROMISE__" type="application/json">55 { ":R2m:": { "data": "Hello world!", "isLoading": false } }56 </script>57 </div>58 <script src="/_next/static/chunks/react-refresh.js?ts=1677395377171"></script>59 <script id="__NEXT_DATA__" type="application/json">60 {61 "props": { "pageProps": {} },62 "page": "/simple",63 "query": {},64 "buildId": "development",65 "nextExport": true,66 "autoExport": true,67 "isFallback": false,68 "scriptLoader": []69 }70 </script>71 </body>72</html>
天気予報を fetch して SSR する例
次は少々コードが長くなりますが、天気予報を気象庁のサイトから持ってきて、SSR で出力する例です。Reload ボタンを用意してあるので再 fetch も可能です。
src/pages/index.tsx
1import { SSRProvider, useSSR } from "next-ssr";23export interface WeatherType {4 publishingOffice: string;5 reportDatetime: string;6 targetArea: string;7 headlineText: string;8 text: string;9}1011/**12 * Data obtained from the JMA website.13 */14const fetchWeather = (id: number): Promise<WeatherType> =>15 fetch(16 `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`17 )18 .then((r) => r.json())19 .then(20 // Additional weights (500 ms)21 (r) => new Promise((resolve) => setTimeout(() => resolve(r), 500))22 );2324/**25 * Components for displaying weather information26 */27const Weather = ({ code }: { code: number }) => {28 const { data, reload, isLoading } = useSSR<WeatherType>(29 () => fetchWeather(code),30 { key: code }31 );32 if (!data) return <div>loading</div>;33 const { targetArea, reportDatetime, headlineText, text } = data;34 return (35 <div36 style={37 isLoading ? { background: "gray", position: "relative" } : undefined38 }39 >40 {isLoading && (41 <div42 style={{43 position: "absolute",44 color: "white",45 top: "50%",46 left: "50%",47 }}48 >49 loading50 </div>51 )}52 <h1>{targetArea}</h1>53 <button onClick={reload}>Reload</button>54 <div>55 {new Date(reportDatetime).toLocaleString("ja-JP", {56 timeZone: "JST",57 })}58 </div>59 <div>{headlineText}</div>60 <div style={{ whiteSpace: "pre-wrap" }}>{text}</div>61 </div>62 );63};6465/**66 * Page display components67 */68const Page = () => {69 return (70 <SSRProvider>71 <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a>72 <hr />73 {/* Chiba */}74 <Weather code={120000} />75 {/* Tokyo */}76 <Weather code={130000} />77 {/* Kanagawa */}78 <Weather code={140000} />79 </SSRProvider>80 );81};82export default Page;
動作画面
コンソールに表示されている通り初期 HTML の時点でデータが揃っています。Reload ボタンを押した場合は、クライアントの処理として気象庁のサイトからデータを再 fetch します。
色々解説
SSR の面倒くさいポイント
Next.js で非同期データを使った SSR を行う場合、通常は getServerSideProps や getInitialProps を使用します。それらによって SSR 時にデータ取得処理を実行し、コンポーネントに渡す必要があります。また、クライアント側でデータの再取得が必要な場合、コンポーネント内にもデータ取得処理を書く必要があります。React Server Components を使用する場合は、コンポーネント内にデータ取得処理を記述できますが、ステートが持てないためクライアント側で再取得することはできず、コンポーネントごと再要求する必要があります。総じて、SSR の実装は面倒です。
サーバ側のコンポーネントレンダリング中に非同期を待つ方法
なぜこのような面倒な処理が必要かというと、Next.js というよりは React の制約のためです。コンポーネントのレンダリングが実行されると非同期を待つことが出来ず、サーバ上ですぐに終了してしまうためです。非同期処理を含めること自体は可能ですが、データ取得命令を発行した直後にレンダリングが完了し、データが到着するのは HTML をクライアントへ送った後になります。React Server Componentsを使った場合、コンポーネントそのものを非同期化出来るので待つことは可能ですが、ステートを持てないという巨大なデメリットを受け入れなければなりません。かといってステートを持たせる部分をClient Componentsで記述すると、その部分は SSR されなくなります。
しかし最初に提示したプログラムでは、普通のコンポーネントが非同期処理の待機をしています。React は実はコンポーネントのレンダリング中に非同期処理を待機することができるのです。この動作を行う唯一の方法は throw promise を使用することです。これは、Suspense と一緒に使用することが前提だと思われていますが、その必要はありません。サーバ側で非同期待機を行いたい場合に使用することができ、promise が解決されたときに再度レンダリングされます。
ちなみに今回は Next.js でサンプルを作っていますが、React の renderToPipeableStream などと組み合わせて、React だけでも同様のことは可能です。今回作ったライブラリで書いたテストは jest 上でそちらを使っています。
https://github.com/ReactLibraries/usessr/blob/master/test/server.test.tsx
サーバ側での非同期待機は特別な方法ではない
サーバ側で throw promise による非同期待機は、React Server Components では普通に利用されている方法です。しかしこれを使った場合、前述したとおり出力したコンポーネントに対してステートが持てないという制約があるので、使いどころが難しくなります。この制約を受けたくなければ、React Server Components を使用する代わりに、通常のコンポーネントで同様のことが行えるようにすれば良いのです。
通常コンポーネントでthrow promiseを使った場合の問題点
React Server Components では、非同期データを扱う場合でも出力される HTML に必要なデータが自動的に組み込まれるため、データを取り扱うために追加の考慮が必要ありません。一方、通常のコンポーネントでは、クライアント側で再マウントされた際にデータが消えてしまいます。HTML に表示用のデータが入っていても、これをコンポーネントがデータとして受け取れないからです。この場合、クライアント側で再フェッチが必要になり SSR の意味が失われることになります。
サーバで生成したthrow promiseのデータをクライアントで有効にする方法
コンポーネントをレンダリングして出力すると、HTML の形式でクライアントに渡すことができますが、これはあくまで見た目のデータを渡しているだけです。再レンダリングにコンポーネントが扱うデータとしては共有されません。そこで、非同期データを JSON 形式に変換して、クライアント側で理解できる形で渡す必要があります。getServerSideProps や getInitialProps を使った場合は、Next.js がこの動作を自動的に行ってくれます。それらを使わない場合は、この処理を手動で実装する必要があります。
<Suspense>は不要
throw promiseとセットで使われることが多い<Suspense>ですが、今回全く出てきていない通り特に使い道はありません。もし使うとするとクライアント側のローディング処理を簡略化する場合かStreaming SSRを行う場合です。前者は throw 対象のコンポーネントと入れ替えて Loading 表示という大味極まりない仕様なので、それなりに適当な UI を設計する時しか使えない機能です。後者はNext.js@13.2時点(13.1 系統までは使用可能)で pages 以下では使用不能になったので意味を成さなくなりました。Streaming SSRは appDir でやれという意図なのでしょう。
一連の処理をライブラリ化したもの
前述した処理を一通り行っているのが以下のソースです。非同期データ待ちと、揃ったデータをクライアントに渡す処理を行っています。
https://www.npmjs.com/package/next-ssr
1import React, {2 ReactNode,3 useContext,4 useId,5 useRef,6 useCallback,7 useSyncExternalStore,8 createContext,9} from "react";1011const DATA_NAME = "__NEXT_DATA_PROMISE__";1213type StateType<T> = {14 data?: T;15 error?: unknown;16 isLoading: boolean;17 fetcher: () => Promise<T>;18};19type Render = () => void;20type ContextType = {21 values: { [key: string]: StateType<unknown> };22 promises: Promise<unknown>[];23 finished: boolean;24 renderMap: Map<string | number, Set<Render>>;25};2627/**28 * Context for asynchronous data management29 */30const promiseContext = createContext<ContextType>(undefined as never);3132/**33 * Rendering event propagation34 */35const render = (36 renderMap: Map<string | number, Set<Render>>,37 key: string | number38) => renderMap.get(key)?.forEach((render) => render());3940/**41 * Asynchronous data loading42 */43const loader = <T,>(44 key: string | number,45 context: ContextType,46 fetcher?: () => Promise<T>47) => {48 const { promises, values, renderMap } = context;49 const _fetcher = fetcher ?? values[key]?.fetcher;50 if (!_fetcher) throw new Error("Empty by fetcher");51 const value = {52 data: values[key]?.data,53 error: undefined,54 isLoading: true,55 fetcher: _fetcher,56 };57 values[key] = value;58 render(renderMap, key);59 const promise = _fetcher();60 if (typeof window === "undefined") {61 promises.push(promise);62 }63 promise64 .then((v) => {65 values[key] = {66 data: v,67 error: undefined,68 isLoading: false,69 fetcher: _fetcher,70 };71 render(renderMap, key);72 })73 .catch((error) => {74 values[key] = {75 data: undefined,76 error,77 isLoading: false,78 fetcher: _fetcher,79 };80 render(renderMap, key);81 });82 return promise;83};8485/**86 * hook for re-loading87 */88export const useReload = (key: string | number) => {89 const context = useContext(promiseContext);90 return useCallback(() => {91 loader(key, context);92 }, [context, key]);93};9495/**96 * Asynchronous data acquisition hook for SSR97 */98export const useSSR = <T,>(99 fetcher: () => Promise<T>,100 { key }: { key?: string | number } = {}101): StateType<T> & { reload: () => void } => {102 const context = useContext(promiseContext);103 const { values, renderMap } = context;104 const id = useId();105 const cacheKey = key ?? id;106107 const value = useSyncExternalStore(108 (callback) => {109 const renderSet = renderMap.get(cacheKey) ?? new Set<Render>();110 renderMap.set(cacheKey, renderSet);111 renderSet.add(callback);112 return () => renderSet.delete(callback);113 },114 () => values[cacheKey] as StateType<T>,115 () => values[cacheKey] as StateType<T>116 );117118 const reload = useCallback(() => {119 return loader(cacheKey, context, fetcher);120 }, [cacheKey, context, fetcher]);121 if (!value) {122 const promise = reload();123 if (typeof window === "undefined") {124 throw promise;125 }126 } else if (!value.fetcher) {127 value.fetcher = fetcher;128 }129130 return { ...value, reload };131};132133/**134 * Transfer of SSR data to clients135 */136const DataRender = () => {137 const context = useContext(promiseContext);138 if (typeof window === "undefined" && !context.finished)139 throw Promise.allSettled(context.promises).then((v) => {140 context.finished = true;141 return v;142 });143 return (144 <script145 id={DATA_NAME}146 type="application/json"147 dangerouslySetInnerHTML={{ __html: JSON.stringify(context.values) }}148 />149 );150};151152/**153 * Context data initialisation hook154 */155const useContextValue = () => {156 const refContext = useRef<ContextType>({157 values: {},158 promises: [],159 finished: false,160 renderMap: new Map<string, Set<Render>>(),161 });162 if (typeof window !== "undefined" && !refContext.current.finished) {163 const node = document.getElementById(DATA_NAME);164 if (node) refContext.current.values = JSON.parse(node.innerHTML);165 refContext.current.finished = true;166 }167 return refContext.current;168};169170/**171 * Provider for asynchronous data management172 */173export const SSRProvider = ({ children }: { children: ReactNode }) => {174 const value = useContextValue();175 return (176 <promiseContext.Provider value={value}>177 {children}178 <DataRender />179 </promiseContext.Provider>180 );181};
まとめ
Next.js の SSR では、非同期データ待ちが throw promise によって可能になり、実装が簡単になりました。この記事では、fetch を使って非同期データを取得する方法を紹介していますが、urql など、サーバ側で throw promise が可能なライブラリを使用する場合も同じように実装ができます。
https://zenn.dev/sora_kumo/articles/661e1abc1cda67
一方で、Recoil や SWR などは対応を試みましたが、現時点ではthrow promiseによる SSR の実装は構造的に不可能でした。@apollo/client は 3.8 系から対応可能となり、試しに作った検証コードでは成功しているものの、まだ 3.8 がアルファ段階です。
今後、throw promiseを内部で呼び出してくれるライブラリが増えることで、SSR の実装がより簡単になることが期待できます。
ちなみに Vercel は SSR 関連の機能として appDir の方を推し進めたいようですが、初っぱなからReact Server Componentsでぶちかますあの仕様は、かなり用途が限定されそうです。