🎉

フルスタックなReact Router v7.0 アプリを Cloudflare Workers にデプロイする

2024/10/21に公開

React Router v7 とは

React Router v7 はreact-router-domの次期バージョンです。Remixと分岐されて開発されていたのが合流するらしいです。

現在pre版でdevブランチで開発されています。ドキュメントが更新されていっているのですがCloudflare Workersにデプロイする方法がまだ書かれていません。なので確認するついでに動作を検証してみました。

https://reactrouter.com/dev/guides/start/installation

プロジェクトの作成

React Router v7 では "react-router-dom" が不要になり、すべて "react-router" からインポートできる。

❯ npx degit remix-run/react-router/templates/basic#dev my-react-router-app
cd my-react-router-app

React RouterのVite pluginを設定する

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [reactRouter(), tsconfigPaths()],
});

全体の構成

❯ tree app/
app/
├── app.css
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│   ├── _index.tsx
│   └── about.tsx
└── routes.ts

flatRoutes について

React Router v7 では、@react-router/fs-routes パッケージを利用することで、ファイルの命名規則に基づいたルート設定が可能になります。

npm i -D @react-router/fs-routes@pre  

app/routes.ts ファイル内で flatRoutes 関数を使用する

src/routes.ts
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";

export const routes: RouteConfig = flatRoutes();

二画面用意する

├── routes
│   ├── _index.tsx
│   └── about.tsx
src/routes/_index.tsx
import { Link, type MetaFunction } from "react-router";

export const meta: MetaFunction = () => {
  return [
    { title: "Home" },
    { name: "description", content: "Welcome to our app!" },
  ];
};

export default function Index() {
  return (
    <div className="flex h-screen items-center justify-center">
      <div className="text-center">
        <h1 className="text-4xl font-bold mb-4">Welcome to our app</h1>
        <Link
          to="/about"
          className="text-blue-500 hover:text-blue-700 underline"
        >
          Go to About page
        </Link>
      </div>
    </div>
  );
}

ハイドレーションをテストしたいのでカウンターを作っておく。

src/routes/about.tsx
import React, { useState } from 'react';

export default function About() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div>
      <h1>About</h1>
      <p>This is the About page.</p>
      <button onClick={handleClick}>
        クリック回数: {count}
      </button>
    </div>
  );
}

サーバーエントリポイント(SSR)

src/entry.server.tsx
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { renderToReadableStream } from "react-dom/server";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext,
  loadContext: AppLoadContext
) {
  const body = await renderToReadableStream(
    <ServerRouter context={routerContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error: unknown) {
        console.error(error);
        responseStatusCode = 500;
      },
    }
  );

  responseHeaders.set("Content-Type", "text/html");
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

クライントエントリポイント(ハイドレーション)

src/entry.client.tsx
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>
  );
});

Cloudflare Workers へのデプロイ

npm install -D @react-router/cloudflare@pre wrangler
vite.config.ts
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
import { reactRouter } from "@react-router/dev/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [cloudflareDevProxy(), reactRouter(), tsconfigPaths()],
});

ビルドをCloudflare Workers形式に変換するためのファイル

functions/\[\[path\]\].ts
import { createPagesFunctionHandler } from "@react-router/cloudflare";
import * as build   from "../build/server";
import type { ServerBuild } from "react-router";

export const onRequest = createPagesFunctionHandler({ build: build as ServerBuild });
npm run build                                                                                                

> build
> react-router build

vite v5.4.9 building for production...
✓ 42 modules transformed.
build/client/.vite/manifest.json                1.29 kB │ gzip:  0.32 kB
build/client/assets/root-tmFNr8je.css           5.51 kB │ gzip:  1.64 kB
build/client/assets/with-props-BaPI_VtF.js      0.19 kB │ gzip:  0.17 kB
build/client/assets/about-BUqouOX8.js           0.35 kB │ gzip:  0.29 kB
build/client/assets/_index-BJPbown-.js          0.54 kB │ gzip:  0.34 kB
build/client/assets/root-DfM8saF3.js            0.78 kB │ gzip:  0.45 kB
build/client/assets/index-CPKbD0DZ.js          99.53 kB │ gzip: 33.12 kB
build/client/assets/entry.client-DGy0bJ3w.js  137.13 kB │ gzip: 44.37 kB
✓ built in 735ms
vite v5.4.9 building SSR bundle for production...
✓ 11 modules transformed.
build/server/.vite/manifest.json               0.23 kB
build/server/assets/server-build-tmFNr8je.css  5.51 kB
build/server/index.js                          6.00 kB
✓ built in 49ms

❯ wrangler pages functions build --outdir build/worker 
❯ npx wrangler dev build/worker/index.js
❯ open http://localhost:8787/
wrangler.toml
name = "my-react-router-app"
main = "./build/worker/index.js"
compatibility_date = "2024-10-18"
compatibility_flags = ["nodejs_compat"]
assets = { directory = "./build/client" }
❯ npx wrangler deploy

SSRが動いていることがわかる

https://my-react-router-app.laiso.workers.dev/

おわりに

  • Remixの中身だった
  • TanStack, Honox等と行っていることは同じだった
  • React Server Componentsの対応が進んだ頃にまた確認します

Discussion