RemixでuseLoaderDataを使わずにSSR
今回のサンプル
SSRの実験用に気象庁からデータを取り寄せてレンダリングしています
Vercel にデプロイしたもの
https://remix-weather.vercel.app/GitHub に置いてあるソースコード
https://github.com/SoraKumo001/remix-weather
Remix の useLoaderData はものすごく不便
Remix が一般に開放されてフロント界隈が少し賑やかになったのを感じます。ということで少しだけ触ってみました。やはり比較対象は Next.js ということになるのですが、Remix はまず初期コードが多く、パッケージだけ入れて颯爽とページ表示までたどり着ける Next.js と比べると、なかなか腰が重くなりそうです。
初期コードの大半は Next.js が隠蔽していじれなくしている部分なので、Remix は一段下のレイヤーの操作が可能でカスタマイズ性と考えることが出来ます。そういうものを必要としている人には大変ありがたい反面、そうでない人には面倒な記述が増えるだけとなってしまいます。
気を取り直してデータの fetch と SSR を試してみます。公式では loader を作って外部からのデータを取得し、useLoaderData を使ってコンポーネントで受け取る形になります。その動きを確認すると Next.js の getServerSideProps のそれと同じです。サーバ側で動作し SSR のデータとして使われ、さらにルーティングによってそのページが再表示されると、キャッシュの有無など関係なくクライアントからのサーバへの再要求と強制ロードがかかるアイツです。
「やめて、データは手元にあるから再ロードやめて!」
そんな声はヤツには届きません。容赦なくルーティング発生とともにデータは再ロードされます。
Remix に Next.js の getInitialProps 的なのは無いのか?
ありません。
くそ、技術でぶん殴ってやる
無いものは仕方が無いので、自分でなんとかします。要するに getInitialProps を Remix で再現すれば良いだけです。構造を見れば何をやれば良いのかは想像が付くので早速実装作業に入ります。
結構便利なのに誰も使ってくれないオレオレライブラリを使用します。元々 Next.js を前提に作ってあるのですが、Remix で使えるように、少し手を入れました。
https://www.npmjs.com/package/@react-libraries/use-ssr
原理的にはこれです。
https://www.apollographql.com/
サーバでコンポーネントを実行し、結果として生成されたキャッシュを元に SSR、そしてデータをクライアントに引き渡して辻褄を合わせます。この動作の何が良いかというと、外部と通信する部分がサーバとクライアントでバラバラに書く必要が無いということです。ただ、これをやってくれるライブラリが apollo-graphql しか存在しなかったので、やりたければ GraphQL という選択しか残されていなかったのです。しかし use-ssr は fetch だろうが GraphQL だろうが Firebase だろうが、好きなもので通信して SSR 化することが出来ます。
ぶん殴った結果
entry.server.tsx
キャッシュの生成と配布を entry.server.tsx で行います。Remix 低レイヤー操作の利点が光ります。
1import { renderToString } from "react-dom/server";2import { RemixServer } from "remix";3import type { EntryContext } from "remix";4import {5 getDataFromTree,6 createContextValue,7 Provider,8} from "@react-libraries/use-ssr";9export default async function handleRequest(10 request: Request,11 responseStatusCode: number,12 responseHeaders: Headers,13 remixContext: EntryContext14) {15 // 仮レンダリングでキャッシュを作成16 const cache = await getDataFromTree(17 <RemixServer context={remixContext} url={request.url} />18 );19 //キャッシュを使ってレンダリング20 const markup = renderToString(21 <Provider value={createContextValue(cache)}>22 <RemixServer context={remixContext} url={request.url} />23 {/* Next.jsがpropsを配る動作を再現 */}24 <script25 id="__REMIX_DATA"26 type="application/json"27 dangerouslySetInnerHTML={{ __html: JSON.stringify(cache) }}28 />29 </Provider>30 );3132 responseHeaders.set("Content-Type", "text/html");33 return new Response("<!DOCTYPE html>" + markup, {34 status: responseStatusCode,35 headers: responseHeaders,36 });37}
entry.client.tsx
document にはサーバから送った HTML が入っているので、キャッシュデータをそこから取り出します。
1import { createCache } from "@react-libraries/use-ssr";2import { hydrate } from "react-dom";3import { RemixBrowser } from "remix";45const cache = document.querySelector("script#__REMIX_DATA")?.innerHTML;6cache && createCache(JSON.parse(cache));7hydrate(<RemixBrowser />, document);
routes/index.tsx
都道府県一覧を出す部分です。fetch で読んで state に突っ込めば SSR されます。サーバとクライアントのコードは統一された状態です。
1import { useSSR } from "@react-libraries/use-ssr";2import { Link } from "remix";34interface Center {5 name: string;6 enName: string;7 officeName?: string;8 children?: string[];9 parent?: string;10 kana?: string;11}12interface Centers {13 [key: string]: Center;14}15interface Area {16 centers: Centers;17 offices: Centers;18 class10s: Centers;19 class15s: Centers;20 class20s: Centers;21}2223const Page = () => {24 const [state, setState] = useSSR<Area | null | undefined>(25 "area",26 async (state, setState) => {27 if (state !== undefined) return;28 setState(null);29 const result = await fetch(30 `https://www.jma.go.jp/bosai/common/const/area.json`31 )32 .then((r) => r.json())33 .catch(() => null);34 setState(result);35 }36 );37 return (38 <div>39 <button onClick={() => setState(undefined)}>Reload</button>40 {state &&41 Object.entries(state.offices).map(([code, { name }]) => (42 <div key={code}>43 <Link to={`/weather/${code}`}>{name}</Link>44 </div>45 ))}46 </div>47 );48};49export default Page;
routes/weather/$id.tsx
コードに対応した気象データの表示をする部分です
1import { useSSR } from "@react-libraries/use-ssr";2import { Link, useParams } from "remix";34export interface Weather {5 publishingOffice: string;6 reportDatetime: Date;7 targetArea: string;8 headlineText: string;9 text: string;10}1112const Page = () => {13 const params = useParams();14 const id = params.id;15 const [state, setState] = useSSR<Weather | null | undefined>(16 ["weather", String(id)] /*CacheKeyName*/,17 async (state, setState) => {18 // When this function finishes, the server side will finish processing and SSR will be performed.19 if (state !== undefined) return;20 setState(null);21 const result = await fetch(22 `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`23 )24 .then((r) => r.json())25 .catch(() => null);26 setState(result);27 }28 );29 return (30 <div>31 <button onClick={() => setState(undefined)}>Reload</button>32 {state && (33 <>34 <h1>{state.targetArea}</h1>35 <div>{new Date(state.reportDatetime).toLocaleString()}</div>36 <div>{state.headlineText}</div>37 <pre>{state.text}</pre>38 </>39 )}40 <div>41 <Link to="/">戻る</Link>42 </div>43 </div>44 );45};4647export default Page;
無事 SSR 完了
1<!DOCTYPE html>2<html lang="en">3 <head>4 <meta charSet="utf-8"/>5 <meta name="viewport" content="width=device-width,initial-scale=1"/>6 <link rel="stylesheet" href="/build/_assets/global-AKFP5T7A.css"/>7 <link rel="stylesheet" href="/build/_assets/dark-APYDFYJA.css" media="(prefers-color-scheme: dark)"/>8 <link rel="stylesheet" href="/build/_assets/remix-5PPS2YMF.css"/>9 </head>10 <body>11 <div class="remix-app">12 <header class="remix-app__header">13 <div class="container remix-app__header-content">14 <a title="Remix" class="remix-app__header-home-link" href="/">15 <svg viewBox="0 0 659 165" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-labelledby="remix-run-logo-title" role="img" width="106" height="30" fill="currentColor">16 <title id="remix-run-logo-title">Remix Logo</title>17 <path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z"></path>18 <path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z"></path>19 <path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z"></path>20 <path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z"></path>21 <path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z"></path>22 </svg>23 </a>24 <nav aria-label="Main navigation" class="remix-app__header-nav">25 <ul>26 <li>27 <a href="/">Home</a>28 </li>29 <li>30 <a href="https://remix.run/docs">Remix Docs</a>31 </li>32 <li>33 <a href="https://github.com/remix-run/remix">GitHub</a>34 </li>35 </ul>36 </nav>37 </div>38 </header>39 <div class="remix-app__main">40 <div class="container remix-app__main-content">41 <div>42 <button>Reload</button>43 <h1>東京都</h1>44 <div>11/25/2021, 12:44:00 PM</div>45 <div>伊豆諸島では、強風や高波に注意してください。</div>46 <pre>日本付近は、冬型の気圧配置となっています。4748 東京地方は、晴れています。4950 25日は、冬型の気圧配置となるため、晴れるでしょう。5152 26日は、引き続き冬型の気圧配置となるため、晴れる見込みです。5354【関東甲信地方】55 関東甲信地方は、おおむね晴れていますが、長野県では雨や雪の降っている所があります。5657 25日から26日は、冬型の気圧配置が続くため、おおむね晴れますが、長野県や関東地方北部の山沿いでは、気圧の谷や寒気の影響により、雨か雪の降る所がある見込みです。5859 関東地方と伊豆諸島の海上では、25日から26日にかけて、波が高いでしょう。船舶は高波に注意してください。</pre>60 <div>61 <a href="/">戻る</a>62 </div>63 </div>64 </div>65 </div>66 <footer class="remix-app__footer">67 <div class="container remix-app__footer-content">68 <p>© You!</p>69 </div>70 </footer>71 </div>72 <script>73 let STORAGE_KEY = "positions";74 if (!window.history.state || !window.history.state.key) {75 window.history.replaceState({76 key: Math.random().toString(32).slice(2)77 }, null);78 }79 try {80 let positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}')81 let storedY = positions[window.history.state.key];82 if (typeof storedY === 'number') {83 window.scrollTo(0, storedY)84 }85 } catch (error) {86 console.error(error)87 sessionStorage.removeItem(STORAGE_KEY)88 }89 </script>90 <link rel="modulepreload" href="/build/_shared/chunk-BGANRL77.js"/>91 <link rel="modulepreload" href="/build/_shared/chunk-NP5RH25Q.js"/>92 <link rel="modulepreload" href="/build/root-EBR73I36.js"/>93 <link rel="modulepreload" href="/build/routes/weather/$id-UOFJWJPH.js"/>94 <script>95 window.__remixContext = {96 'matches': [{97 'params': {98 'id': '130000'99 },100 'pathname': '/',101 'route': {102 'id': 'root',103 'path': '',104 'module': '/build/root-EBR73I36.js',105 'hasAction': false,106 'hasLoader': false,107 'hasCatchBoundary': true,108 'hasErrorBoundary': true109 }110 }, {111 'params': {112 'id': '130000'113 },114 'pathname': '/weather/130000',115 'route': {116 'id': 'routes/weather/$id',117 'parentId': 'root',118 'path': 'weather/:id',119 'module': '/build/routes/weather/$id-UOFJWJPH.js',120 'hasAction': false,121 'hasLoader': false,122 'hasCatchBoundary': false,123 'hasErrorBoundary': false124 }125 }],126 'componentDidCatchEmulator': {127 'trackBoundaries': true,128 'trackCatchBoundaries': true,129 'catchBoundaryRouteId': 'root',130 'renderBoundaryRouteId': null,131 'loaderBoundaryRouteId': 'root',132 'error': undefined,133 'catch': undefined134 },135 'routeData': {136 'root': null,137 'routes/weather/$id': null138 },139 'actionData': undefined140 };141 </script>142 <script src="/build/manifest-E90BAC30.js"></script>143 <script type="module">144 import * as route0 from "/build/root-EBR73I36.js";145import * as route1 from "/build/routes/weather/$id-UOFJWJPH.js";146window.__remixRouteModules = {"root":route0,"routes/weather/$id":route1};147 </script>148 <script src="/build/entry.client-EA7RYRFN.js" type="module"></script>149 </body>150</html>151<script id="__REMIX_DATA" type="application/json">152 {"[@react-libraries/use-ssr][weather][130000]":{"publishingOffice":"気象庁","reportDatetime":"2021-11-25T21:44:00+09:00","targetArea":"東京都","headlineText":"伊豆諸島では、強風や高波に注意してください。","text":" 日本付近は、冬型の気圧配置となっています。\n\n 東京地方は、晴れています。\n\n 25日は、冬型の気圧配置となるため、晴れるでしょう。\n\n 26日は、引き続き冬型の気圧配置となるため、晴れる見込みです。\n\n【関東甲信地方】\n 関東甲信地方は、おおむね晴れていますが、長野県では雨や雪の降っている所があります。\n\n 25日から26日は、冬型の気圧配置が続くため、おおむね晴れますが、長野県や関東地方北部の山沿いでは、気圧の谷や寒気の影響により、雨か雪の降る所がある見込みです。\n\n 関東地方と伊豆諸島の海上では、25日から26日にかけて、波が高いでしょう。船舶は高波に注意してください。"}}153</script>
ということで、無事に簡単なコードで SSR が出来るようになりました。ぶん殴ったことは反省していません。