空雲 Blog

Eye catchReactのSSRにフレームワークの機能は必要ない、Remixの機能に依存せずReactの標準機能でSSR

publication: 2024/08/04
update:2024/08/07

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";
11
12export function Layout({ children }: { children: React.ReactNode }) {
13 return (
14 <RemixHeadProvider>
15 <html lang="ja">
16 <SSRProvider>
17 <head>
18 <meta charSet="utf-8" />
19 <meta
20 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}
39
40export default function App() {
41 return <Outlet />;
42}

routes/_index.tsx

千葉、東京、神奈川の天気予報のリンクを作成します。RemixHead で、タイトルタグは SSR 時に head 内に出力されます。

1import { Link } from "@remix-run/react";
2import { RemixHead } from "remix-head";
3
4export 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";
4
5export interface WeatherType {
6 publishingOffice: string;
7 reportDatetime: string;
8 targetArea: string;
9 headlineText: string;
10 text: string;
11}
12
13const 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 <button
43 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 Reload
47 </button>
48 </h1>
49
50 <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};
61
62export 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">関東甲信地方は、緩やかに高気圧に覆われています。
29
30 千葉県は、晴れています。
31
32 4日は、高気圧に覆われますが、湿った空気の影響を受けるため、晴れ夕方から曇りとなるでしょう。
33
34 5日は、引き続き、緩やかに高気圧に覆われますが、湿った空気の影響を受けるため、晴れ時々曇りとなる見込みです。
35
36 千葉県の太平洋沿岸の海上では、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: key2
44 }, "");
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": false
76 },
77 "isSpaMode": false,
78 "state": {
79 "loaderData": {
80 "root": null,
81 "routes/weather.$id": null
82 },
83 "actionData": null,
84 "errors": null
85 }
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";
92
93 window.__remixRouteModules = {
94 "root": route0,
95 "routes/weather.$id": route1
96 };
97
98 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": false
112 }
113 }</script>
114</html>

まとめ

RemixやNext.jsなどの種類に関係なく、React の SSR にフレームワークの機能は必要ありません。React の標準機能だけで実現することが可能です。この記事では、throw promiseを使ってコンポーネントの評価順を制御し、データの出力を行いました。また、データルーティングを行うことで、サーバ側のデータをクライアント側で再利用しています。これにより、フレームワークの機能を使わずに、React の標準機能だけで SSR を実現することができました。