空雲リファレンス

[React/Next.js]react@next(18.3系統)で実装されたuseを使ってNext.jsでSSRする

Reactのuse

Reactの18.3系統で実装されたuseを使うと、コンポーネント内でPromiseの解決が出来ます。非同期処理が直列で書けるので、コードの内容がかなりすっきりします。

現時点でReact18.3系統を使うには、以下のようにパッケージをインストールする必要があります。

yarn add react@next react-dom@next

このuseを使う場合、

  • SSRを切ってcacheを使う
  • ServerComponentsを使う

などの情報が今後増えてくると思われますが、この記事では一切やりません。SSRも切らず、ServerComponentsも使わず、とにかく便利に使えそうな方法で実装します。

1.動作確認 & サンプル

https://next-use-ssr.vercel.app/
https://github.com/SoraKumo001/next-use-ssr

2.作ったものの実行例

3.動作に関して

まず大前提として、今回のプログラムはServerComponentsを使用していません。Next.jsでServerComponentsを使用せずにSSRでSuspenseを行うと、以下のような動作になります。

3.1.Suspenseと非同期をそのまま使った場合

  • throw promise or use(promise)が発生した時点でSuspense位置までのHTMLを出力
  • promiseが解決した時点でHTMLを追加出力
  • クライアント側で追加出力されたHTMLをJavaScriptが所定の位置へ再配置
  • クライアント側でコンポーネントの動作開始
  • クライアントはサーバでfetchしたデータを持っていないので、再fetchして初期HTMLを破壊し再レンダリングを始める

ものの見事にサーバ側でfetchした意味がありません。サーバとクライアントの連携が取れていません。

SSRを切ってクライアントのみで動くようにしたり、ServerComponentsでクライアントの再レンダリングを回避したりというのはこれが理由です。

3.2.サーバ側のデータをクライアントに受け渡す構造を作った場合

以下の方法で、クライアント再fetch問題を解決します。

  • throw promise or use(promise)が発生した時点でSuspense位置までのHTMLを出力
  • promiseが解決した時点でHTMLを追加出力し、さらにデータ専用ノードを作成してクライアントで受け取れるようにする
  • クライアント側で追加出力されたHTMLをJavaScriptが所定の位置へ再配置
  • クライアント側でコンポーネントの動作開始し、データ専用ノードからデータを取得
  • クライアントはサーバでfetchしたデータを持っているので、再fetchせずに初期HTMLを破壊しない形で再レンダリングされる

HTML内にクライアントで取得可能なデータを混ぜ込むことによって、サーバ側のデータは無駄にされず、クライアントで余計なfetchを防ぐことが出来ました。ただしこの仕組み、Suspenseを使った時点で問題が発生します。それはSSRしたHTMLデータが最終的な完成形に変換される際に、再配置のためのJavaScriptが必要となるからです。検索エンジンにキャッシュしてもらう場合などは、JavaScriptも理解してもらえるので問題ありません。しかしSNS系にリンクを貼り付けてOGPを解釈させるような場合はJavaScriptの再配置が行われないため、それが無かったことにされる可能性が高いのです。

3.3.Suspenseを使いつつ、完成形のHTMLを吐き出す構造を作る

Suspenseは非同期を扱う際に、読み込み中などの途中経過を表示できます。逆に言うと、途中経過を吐き出すための仕組みなです。これを使う限り完成前のHTMLデータがいったん出力されてしまいます。しかしNext.jsでこれを回避する方法があります。Suspenseしないthrow promiseを飛ばすことです。

  • throw promise or use(promise)が発生した時点でSuspenseしつつ、それとは別にSuspenseしないthrow promiseを作る
  • throw promiseSuspenseされていない場合、Promiseが解決されるまでHTMLの出力が待機状態となる
  • Promiseが解決した時点で完成版のHTMLにデータ専用ノードを作成してクライアントで受け取れるようにする
  • クライアント側でHTMLを受け取るが、HTMは完成しているので再配置不要
  • クライアント側でコンポーネントの動作開始し、データ専用ノードからデータを取得
  • クライアントはサーバでfetchしたデータを持っているので、再fetchせずに初期HTMLを破壊しない形で再レンダリングされる

以上の動作によって、非同期データが完成版のHTMLに入った状態でSSRされます。再配置のためのJavaScriptは不要なため、OGPなどにも問題は発生しません。

余談ですが、クライアント側でSuspenseしないthrow promiseを使うと、ステートを吹き飛ばした状態で最上位から再レンダリングが発生し制御不能になります。

4.サンプルソース

useを使って、気象庁のサイトからデータを取り出すNext.jsのサンプルです。
useによって非同期処理の状態変化の取り回しがSuspense1つで済むので、かなりコードがすっきりします。

そして今回実装したusePromiseStateにPromiseを格納すれば、SSRに必要な動作を自動でやってくれます。

  • src/pages/index.tsx
import { Suspense, use } from 'react'; import { usePromiseState } from '../libs/promiseState'; export interface WeatherType { publishingOffice: string; reportDatetime: Date; targetArea: string; headlineText: string; text: string; } const fetchWeather = (id: number) => fetch(`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`).then((r) => r.json() ); const Weather = ({ weather: p }: { weather: Promise<WeatherType> }) => { //Reactの新機能useでデータを取り出す const weather = use(p); return ( <div> <h1>{weather.targetArea}</h1> <div> {new Date(weather.reportDatetime).toLocaleString('ja-JP', { year: 'numeric', month: 'narrow', day: 'numeric', hour: 'numeric', minute: 'numeric', })} </div> <div>{weather.headlineText}</div> <pre>{weather.text}</pre> </div> ); }; const Page = () => { //初期値はcallbackで渡す const [weather, setWeather] = usePromiseState(() => fetchWeather(130000)); return ( <div> <div> <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a> </div> <hr /> <div> {/* 後からstateを変更する場合は、そのままpromiseを格納してOK */} <button onClick={() => setWeather(fetchWeather(130000))}>東京</button> <button onClick={() => setWeather(fetchWeather(120000))}>千葉</button> <button onClick={() => setWeather(fetchWeather(140000))}>神奈川</button> </div> <Suspense fallback={'Loading'}> <Weather weather={weather} /> </Suspense> </div> ); }; export default Page;
  • src/pages/_app.tsx

サーバ側でfetchしたデータをクライアントに受け渡す処理がPromiseProviderに入っています。

import { AppProps } from 'next/app'; import { PromiseProvider } from '../libs/promiseState'; const App = ({ Component }: AppProps) => { return ( <PromiseProvider> <Component /> </PromiseProvider> ); }; export default App;
  • src/libs/promiseState.tsx

usePromiseStateの実装です。
サーバで生成されたPromiseを解決して、クライアントに飛ばしています。
SSR時にSuspenseで中断された状態のHTMLが吐き出されないように細工も入れています。 そのため、出力される初期HTMLは非同期データも含まれた完成形のHTMLになり、JavaScriptによる再配置を必要としません。

import { ReactNode, useContext, useId, useRef, useState, createContext } from 'react'; const DATA_NAME = '__NEXT_DATA_PROMISE__'; type ContextType = { values: { [key: string]: unknown }; promises: Promise<unknown>[]; finished: boolean; }; const promiseContext = createContext<ContextType>(undefined as never); export const usePromiseState = <T,>(p: Promise<T> | (() => Promise<T>)) => { const context = useContext(promiseContext); const id = useId(); const [state, setState] = useState<Promise<T>>(() => { if (typeof window === 'undefined') { const promise = typeof p === 'function' ? p() : p; context.promises.push(promise); promise.then((v) => { context.values[id] = v; }); return promise; } return context.values[id] ? Promise.resolve(context.values[id] as T) : typeof p === 'function' ? p() : p; }); return [state, setState] as const; }; const DataRender = () => { const context = useContext(promiseContext); if (typeof window === 'undefined' && !context.finished) throw Promise.all(context.promises).then((v) => { context.finished = true; return v; }); return ( <script id={DATA_NAME} type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify(context.values) }} /> ); }; const useContextValue = () => { const refContext = useRef<ContextType>({ values: {}, promises: [], finished: false }); if (typeof window !== 'undefined' && !refContext.current.finished) { const node = document.getElementById(DATA_NAME); if (node) refContext.current.values = JSON.parse(node.innerHTML); refContext.current.finished = true; } return refContext.current; }; export const PromiseProvider = ({ children }: { children: ReactNode }) => { const value = useContextValue(); return ( <promiseContext.Provider value={value}> {children} <DataRender /> </promiseContext.Provider> ); };
  • 生成されたHTMLデータ

JavaScript無しの初期状態でデータが揃っています。

<!DOCTYPE html> <html> <head> <meta charSet="utf-8"/> <meta name="viewport" content="width=device-width"/> <meta name="next-head-count" content="2"/> <style data-next-hide-fouc="true"> body { display: none } </style> <noscript data-next-hide-fouc="true"> <style> body { display: block } </style> </noscript> <noscript data-n-css=""></noscript> <script defer="" nomodule="" src="/_next/static/chunks/polyfills.js?ts=1667089915626"></script> <script src="/_next/static/chunks/webpack.js?ts=1667089915626" defer=""></script> <script src="/_next/static/chunks/main.js?ts=1667089915626" defer=""></script> <script src="/_next/static/chunks/pages/_app.js?ts=1667089915626" defer=""></script> <script src="/_next/static/chunks/pages/index.js?ts=1667089915626" defer=""></script> <script src="/_next/static/development/_buildManifest.js?ts=1667089915626" defer=""></script> <script src="/_next/static/development/_ssgManifest.js?ts=1667089915626" defer=""></script> <noscript id="__next_css__DO_NOT_USE__"></noscript> </head> <body> <div id="__next"> <div> <div> <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a> </div> <hr/> <div> <button>東京</button> <button>千葉</button> <button>神奈川</button> </div> <!--$--> <div> <h1>東京都</h1> <div>2022年10月30日 4:44</div> <div></div> <pre>東日本は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。  東京地方は、晴れや曇りとなっています。  30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。  31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。 【関東甲信地方】  関東甲信地方は、晴れや曇りとなっています。  30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。  31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。  関東地方と伊豆諸島の海上では、31日にかけてうねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。</pre> </div> <!--/$--> </div> <script id="__NEXT_DATA_PROMISE__" type="application/json"> {":R2m:":{"publishingOffice":"気象庁","reportDatetime":"2022-10-30T04:44:00+09:00","targetArea":"東京都","headlineText":"","text":" 東日本は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。\n\n 東京地方は、晴れや曇りとなっています。\n\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。\n\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。\n\n【関東甲信地方】\n 関東甲信地方は、晴れや曇りとなっています。\n\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\n\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\n\n 関東地方と伊豆諸島の海上では、31日にかけてうねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。"}} </script> </div> <script src="/_next/static/chunks/react-refresh.js?ts=1667089915626"></script> <script id="__NEXT_DATA__" type="application/json"> {"props":{"pageProps":{}},"page":"/","query":{},"buildId":"development","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]} </script> </body> </html>

5.まとめ

Next.jsでSSRを行う場合、getInitialPropsgetServerSidePropsなどを使うと、コンポーネントとは別の場所にデータ取得のコードを書く必要があります。それが面倒な場合はデータ取得用のプリレンダーを作っていったん内部でデータを完成させて再配布という機能を作れば良いのですが、構造が複雑になります。かといってServerComponentsを使うと、コンポーネントを柔軟に取り扱いたい時に制約が多く、面倒事が増えます。

そんな面倒ごとを考えず、コンポーネント内で非同期データを突っ込んでSSRするという仕組みを今回やってみました。結果、コンポーネント上に直接非同期処理を乗せて、データが出そろった状態でSSRさせることに成功しました。クライアントにHTMLが渡った後も、もちろん動的にコンポーネントを動かせます。難しいことも面倒なことも何もありません。

もしや、かなりヤバい方法を発見したのではないかと思っています。