🫸

RailsとReact Routerをシンプルに繋げる

2025/02/15に公開

モチベーション

Ruby on Railsは大好きだが、Hotwire には慣れない。モダンな方法で画面を実装したい。

方針

  • React Router の SPA用設定 (ssr: false)を使う
    • 個人開発のシンプルなアプリにとってSSRは不要な複雑さがある
  • kamal でデプロイする。ワンサーバーでRailsのコンテナだけ動かす
  • react-router <-> Rails間はクッキーでセッションを貼る

方法

react-routerのセットアップ

# railsルートでfrontend ディレクトリにreact-router appを作成する
npx create-react-router@latest frontend

ssr設定をfalseに。 loader action などは使えなくなるので、 clientLoader clientAction を使うようにすること。

frontend/react-router.config.ts
export default {
   // Config options...
   // Server-side render by default, to enable SPA mode set this to `false`
-  ssr: true,
+  ssr: false,
 } satisfies Config;

viteの設定。HMRのためのサーバのポートを適当にずらしてあげる必要がある

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

export default defineConfig({
  server: {
    // only for development
    hmr: {
      port: 3001, // 開発環境のrailsのプロキシを迂回するようにreact-router dev serverのデフォルトポート(5173)から明示的にずらす
    },
  },

  plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});

Railsを設定

Gemfile
gem "rack-proxy", "~> 0.7.7"

開発用に使うRackミドルウェア rack-proxy をインストールしておく。
次にルーティングを設定する。これが設定の肝

config/environment/production.rb
+  class FrontendStatic < ActionDispatch::Static; end
+  config.middleware.insert_before ActionDispatch::Static, FrontendStatic, "#{Rails.root}/public/react-router/client"
config/routes.rb
Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check

  # ... ここに好きなルーティングを書く ...

  # SPA用の設定
  # ---以下の設定はroutingの最後にマッチするようにファイルの最後に置いてください---
  if Rails.env.production? || ENV["FORCE_SPA"]
    class SPAHandler
      def initialize
        @file_handler = ActionDispatch::FileHandler.new(Rails.root.join("public", "react-router", "client").to_s)
      end

      def call(env)
        env["PATH_INFO"] = "index.html" # always serve the index.html
        @file_handler.call(env)
      end
    end

    get "*path", to: SPAHandler.new
  else
    proxy = Rack::Proxy.new(backend: "http://localhost:5173") # to react-router dev server
    root to: proxy
    get "*path", to: proxy
  end
end

本番環境ではActionDispatch::Staticミドルウェアを使い、優先してreact-routerのビルド物を返すようにする。これにより.js, .cssなどのアセットが配信される。
またSPAHandler ミドルウェアにより、Rails上でマッチしなかったルーティングはすべて react-routerのビルド結果の index.html を返すようになる。

開発環境では rack-proxyを使い、Rails上でマッチしなかったルーティングをすべて react-routerのdev serverに流す。 rootも設定しておかないとwelcome画面が出るのでrootも設定する。

ビルドの設定

Railsのassets:precompileをフックして react-routerのビルドを行うようにする。

lib/tasks/react_router.rake を作成して以下のようにしておく。

lib/tasks/react_router.rake
namespace :react_router do
  desc "Build React Router App"
  task build: :environment do |_, args|
    Dir.chdir(Rails.root.join("frontend")) do
      system("pnpm", "run", "build", exception: true)

      # Copy the react-router build to the Rails public directory
      FileUtils.cp_r("build", Rails.root.join("public", "react-router"))
    end
  end

  task clobber: :environment do
    FileUtils.rm_rf(Rails.root.join("public", "react-router"))
  end
end

Rake::Task["assets:precompile"].enhance([ "react_router:build" ])
Rake::Task["assets:clobber"].enhance([ "react_router:clobber" ])

ビルド生成物を .gitignoreに追加しておく

.gitignore
+ /public/react-router

Procfileを作成(optional)

開発に必要なサーバを bin/dev で一括で起動して開発できるようにする。

Rails のプロジェクトに bin/dev が存在していなかったら以下を作る。 [1]

bin/dev
#!/usr/bin/env sh

if ! gem list foreman -i --silent; then
  echo "Installing foreman..."
  gem install foreman
fi

# Default to port 3000 if not specified
export PORT="${PORT:-3000}"

# Let the debug gem allow remote connections,
# but avoid loading until `debugger` is called
export RUBY_DEBUG_OPEN="true"
export RUBY_DEBUG_LAZY="true"

exec foreman start -f Procfile.dev "$@"
Procfile.dev
web: bin/rails server
frontend: cd frontend && pnpm run dev

まとめ

以上でデプロイ可能なRailsモダンフロントエンドができる。Railsやっていきましょう

脚注
  1. https://github.com/rails/tailwindcss-rails/blob/465e118ed15ae87b45030eb20a59ace245cf4bf1/lib/install/dev#L1 ↩︎

Discussion