ReactのSSRにフレームワークの機能は必要ない、Remixの機能に依存せずReactの標準機能でSSR
React の SSR にフレームワークの機能は必要ない
React で SSR を行う際、フレームワークの機能を使わずに React の標準機能だけで実現する方法を紹介します。Next.js でも同じ方法が有効なので、これを使えば Remix への依存が本当の意味で最小になります。
Remix での一般的な方法だと SSR を行う場合、routes 直下のファイルで実装した loader 関数を使ってデータを作成し、各コンポーネント内の useLoaderData データを受け取ります。この方法だと、ページの頭でどんなデータを取得するかを決めなければならず、コンポーネントの状態に合わせて柔軟にデータを用意することが困難です。
実はそんな方法を使わずとも React には、コンポーネント側でデータを取得する機能が用意されています。もちろん React の利点を殺す ServerComponents のことではありません。普通のコンポーネントで実現可能なのです。
React の標準機能で SSR を行う際の必要なテクニック
throw promise
コンポーネントで外部にあるデータを持ってくる際は、非同期という扱いになります。React の一般コンポーネントを SSR でレンダリングする場合は、同期的に実行されなければなりません。ではどうやって非同期処理を同期的に扱うかというと、throw promiseを使います。これを使うことで、コンポーネントの評価を一旦スキップすることができます。この機能により、コンポーネントの評価タイミングを自由に調整し、実態は非同期なのに、コンポーネントは同期状態という形で SSR が可能になります。
データルーティング
サーバ側で必要なデータを取得
そのデータを HTML に変換して出力
クライアント側でその HTML を受け取り、仮想 DOM を構築し対応したノードをマウント
クライアント側で再レンダリング ← ここで問題が発生
SSR ではサーバ側で必要なデータを揃えて、それを HTML に変換して出力します。クライント側ではその HTML を受け取った後に、仮想 DOM を構築し対応したノードをマウントします。そのままだと、マウント完了後の再レンダリング時に問題が発生します。サーバ側が持っていたデータが何なのかクライアントは知らないからです。HTML の中にはデータが入っていても、クライアントで実行されるスクリプト側にはデータが入っていないのです。すると、空データで再レンダリングされてしまい、せっかくサーバ側で吐き出したデータが消えてしまいます。
これに対処するには、サーバ側のデータをクライアントが受け取れるようにします。具体的な方法としては、データを JSON 化して HTML に埋め込みます。クライアント側はその JSON データを取得し、それを使って再レンダリングを行います。これにより、サーバ側で取得したデータをクライアント側で再利用することができます。
サーバーとクライアントの処理
throw promiseによるコンポーネントの評価順を制御することで、データが出揃うのを待つコンポーネントを作ることが出来ます。このコンポーネントでデータの JSON 化してレンダリングすることでサーバ側の出力は完了です。
クライアントは、サーバ側で出力された JSON データを取得し、初期データとして設定します。その後、クライアント側で再レンダリングを行います。この時、初期データがあるため、再レンダリング時にデータが消えることはありません。
実装例
Cloudflareにデプロイしたもの
https://cloudflare-remix-ssr.pages.dev/
ソースコード
https://github.com/SoraKumo001/cloudflare-remix-ssr
以下の二種類のパッケージを使います
SSR の制御を行うパッケージ
もともと Next.js 用に作ったののですが、React の標準機能しか使ってないので Remix でも動作します
https://www.npmjs.com/package/next-ssr
Remix での head の制御を行うパッケージ
title タグをコンポーネント内で設定するのに使います
https://www.npmjs.com/package/remix-head
root.tsx
RemixHeadProvider(head 制御用)と SSRProvider(SSR データ管理用)を設置します。さらに、head タグ内に RemixHeadRoot を設置して、title タグを出力する場所を作ります。
1import {2 Links,3 Meta,4 Outlet,5 Scripts,6 ScrollRestoration,7} from "@remix-run/react";8import "./tailwind.css";9import { SSRProvider, SSRWait } from "next-ssr";10import { RemixHeadProvider, RemixHeadRoot } from "remix-head";1112export function Layout({ children }: { children: React.ReactNode }) {13 return (14 <RemixHeadProvider>15 <html lang="ja">16 <SSRProvider>17 <head>18 <meta charSet="utf-8" />19 <meta20 name="viewport"21 content="width=device-width, initial-scale=1"22 />23 <Meta />24 <Links />25 <SSRWait>26 <RemixHeadRoot />27 </SSRWait>28 </head>29 <body>30 {children}31 <ScrollRestoration />32 <Scripts />33 </body>34 </SSRProvider>35 </html>36 </RemixHeadProvider>37 );38}3940export default function App() {41 return <Outlet />;42}
routes/_index.tsx
千葉、東京、神奈川の天気予報のリンクを作成します。RemixHead で、タイトルタグは SSR 時に head 内に出力されます。
1import { Link } from "@remix-run/react";2import { RemixHead } from "remix-head";34export default function Index() {5 const codes = { 120000: "千葉", 130000: "東京", 140000: "神奈川" } as const;6 return (7 <div className="p-2">8 <RemixHead>9 <title>天気予報</title>10 </RemixHead>11 <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a>12 <hr />13 <div className="flex flex-col">14 {Object.entries(codes).map(([key, value]) => (15 <Link key={key} to={`/weather/${key}`} className="underline">16 {value}の天気17 </Link>18 ))}19 </div>20 </div>21 );22}
weather.$id.tsx
useSSR を使って、天気予報のデータを取得します。SSR 時は内部でthrow promiseを行い、データの取得が完了するまでコンポーネントの評価をスキップします。データルーティングも自動で行われるため、クライアント側で処理される時点で、データは持った状態で始まります。その後、ユーザーの操作によってデータを再取得することも可能です。
また、動作がわかりやすいように 500ms の遅延を追加しています。
ブラウザでページを更新した際は SSR でサーバ側がデータの取得を行い、ページ遷移した場合はクライアント側がデータの取得を行います。
1import { Link, useParams } from "@remix-run/react";2import { useSSR } from "next-ssr";3import { RemixHead } from "remix-head";45export interface WeatherType {6 publishingOffice: string;7 reportDatetime: string;8 targetArea: string;9 headlineText: string;10 text: string;11}1213const Weather = ({ code }: { code: number }) => {14 const { data, reload, isLoading } = useSSR<WeatherType>(15 () =>16 fetch(17 `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${code}.json`18 )19 .then((r) => r.json<WeatherType>())20 .then(21 // Additional weights (500 ms)22 (r) => new Promise((resolve) => setTimeout(() => resolve(r), 500))23 ),24 { key: code }25 );26 if (!data) return <div>loading</div>;27 const { targetArea, reportDatetime, headlineText, text } = data;28 return (29 <>30 <div>31 <Link to="..">戻る</Link>32 </div>33 <div className={`mt-4${isLoading ? " bg-gray-500 relative" : ""}`}>34 {isLoading && (35 <div className="absolute text-white top-1/2 left-1/2">loading</div>36 )}37 <RemixHead>38 <title>{`${targetArea}の天気`}</title>39 </RemixHead>40 <h1 className="flex text-4xl font-extrabold leading-none items-center gap-2">41 {targetArea}42 <button43 className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"44 onClick={reload}45 >46 Reload47 </button>48 </h1>4950 <div>51 {new Date(reportDatetime).toLocaleString("ja-JP", {52 timeZone: "JST",53 })}54 </div>55 <div>{headlineText}</div>56 <div style={{ whiteSpace: "pre-wrap" }}>{text}</div>57 </div>58 </>59 );60};6162export default function Page() {63 const { id } = useParams<{ id: string }>();64 return <Weather code={Number(id)} />;65}
出力されたHTML
loaderやmetaのエクスポート無しでデータ込みのHTMLが出力されています。
1<!DOCTYPE html>2<html lang="ja">3 <head>4 <meta charSet="utf-8"/>5 <meta name="viewport" content="width=device-width, initial-scale=1"/>6 <link rel="stylesheet" href="/assets/root-Cwtj2YX8.css"/>7 <script id="__REMIX_HEAD_VALUE__" type="application/json">8 [9 {10 "type": "title",11 "props": {12 "children": "千葉県の天気"13 }14 }15 ]</script>16 <title>千葉県の天気</title>17 </head>18 <body>19 <div>20 <a data-discover="true" href="/">戻る</a>21 </div>22 <div class="mt-4">23 <h1 class="flex text-4xl font-extrabold leading-none items-center gap-2">24 千葉県<button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">Reload</button>25 </h1>26 <div>2024/8/4 10:34:00</div>27 <div></div>28 <div style="white-space:pre-wrap">関東甲信地方は、緩やかに高気圧に覆われています。2930 千葉県は、晴れています。3132 4日は、高気圧に覆われますが、湿った空気の影響を受けるため、晴れ夕方から曇りとなるでしょう。3334 5日は、引き続き、緩やかに高気圧に覆われますが、湿った空気の影響を受けるため、晴れ時々曇りとなる見込みです。3536 千葉県の太平洋沿岸の海上では、4日から5日にかけて、うねりを伴い波がやや高いでしょう。</div>37 </div>38 <script>39 ((STORAGE_KEY2,restoreKey)=>{40 if (!window.history.state || !window.history.state.key) {41 let key2 = Math.random().toString(32).slice(2);42 window.history.replaceState({43 key: key244 }, "");45 }46 try {47 let positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY2) || "{}");48 let storedY = positions[restoreKey || window.history.state.key];49 if (typeof storedY === "number") {50 window.scrollTo(0, storedY);51 }52 } catch (error) {53 console.error(error);54 sessionStorage.removeItem(STORAGE_KEY2);55 }56 }57 )("positions", null)58 </script>59 <link rel="modulepreload" href="/assets/manifest-4d736570.js"/>60 <link rel="modulepreload" href="/assets/entry.client-DKQ85Fyp.js"/>61 <link rel="modulepreload" href="/assets/components-33g9JuwS.js"/>62 <link rel="modulepreload" href="/assets/index-vCBuBoWv.js"/>63 <link rel="modulepreload" href="/assets/index-BiVa1t-t.js"/>64 <link rel="modulepreload" href="/assets/root-D4NJm1pT.js"/>65 <link rel="modulepreload" href="/assets/weather._id-9DOUjnGC.js"/>66 <script>67 window.__remixContext = {68 "url": "/weather/120000",69 "basename": "/",70 "future": {71 "v3_fetcherPersist": true,72 "v3_relativeSplatPath": true,73 "v3_throwAbortReason": true,74 "unstable_singleFetch": false,75 "unstable_fogOfWar": false76 },77 "isSpaMode": false,78 "state": {79 "loaderData": {80 "root": null,81 "routes/weather.$id": null82 },83 "actionData": null,84 "errors": null85 }86 };87 </script>88 <script type="module" async="">89 import "/assets/manifest-4d736570.js";90 import*as route0 from "/assets/root-D4NJm1pT.js";91 import*as route1 from "/assets/weather._id-9DOUjnGC.js";9293 window.__remixRouteModules = {94 "root": route0,95 "routes/weather.$id": route196 };9798 import("/assets/entry.client-DKQ85Fyp.js");99 </script>100 </body>101 <script id="__NEXT_DATA_PROMISE__" type="application/json">102 {103 "120000": {104 "data": {105 "publishingOffice": "銚子地方気象台",106 "reportDatetime": "2024-08-04T10:34:00+09:00",107 "targetArea": "千葉県",108 "headlineText": "",109 "text": " 関東甲信地方は、緩やかに高気圧に覆われています。\n\n 千葉県は、晴れています。\n\n 4日は、高気圧に覆われますが、湿った空気の影響を受けるため、晴れ夕方から曇りとなるでしょう。\n\n 5日は、引き続き、緩やかに高気圧に覆われますが、湿った空気の影響を受けるため、晴れ時々曇りとなる見込みです。\n\n 千葉県の太平洋沿岸の海上では、4日から5日にかけて、うねりを伴い波がやや高いでしょう。"110 },111 "isLoading": false112 }113 }</script>114</html>
まとめ
RemixやNext.jsなどの種類に関係なく、React の SSR にフレームワークの機能は必要ありません。React の標準機能だけで実現することが可能です。この記事では、throw promiseを使ってコンポーネントの評価順を制御し、データの出力を行いました。また、データルーティングを行うことで、サーバ側のデータをクライアント側で再利用しています。これにより、フレームワークの機能を使わずに、React の標準機能だけで SSR を実現することができました。