はじめに
Ruby on Railsを利用したWebシステムを開発では、バックエンドこそRailsであるものの、フロントエンドには別のフレームワークを利用する風景をよく見ます。
しかし、バックエンド・フロントエンドを分離するためには乗り越えるべき課題があり、注力したい課題解決を遅らせてしまう可能性があります。
本記事では、できるだけ単純に(デフォルトの機能を活かして)RailsにReactを導入する方法を模索した結果を共有します。
今回はバックエンドにRuby on Rails 8.1、フロントエンドにVite 4.1(React 19.1)を利用します。
また、スタイリングにTailwindCSSを利用します。
バックエンドとフロントエンドの分離
課題は?
例えばRailsをREST APIサーバ、React(などのフロントエンドフレームワーク)でフロントエンド全体とする構成では、次のような課題があります。
- Cross-Origin Resource Sharing(CORS)設定 ※ Rails/Reactを別のサーバでホストする場合
- Cross-Site Request Forgery(CSRF)対策
- 資源適用プロセスの複雑化
- 依存の増加
- フロントエンドのエラーハンドリング複雑化
- レイテンシ増加 ※ UI描画→API取得の場合
- データ過不足(オーバーフェッチング・アンダーフェッチング)
- WebSocketを扱うのが大変 ※ 双方向通信したい場合
これらは製品が解決したい課題に対して非本質的であり、Railsのみを利用する場合は発生しないものです。
「開発生産性が高い(市場投入までの期間が短い)」ことを理由としてRailsを選定する場合、ここで発生するコストは相対的に重く、無視できないものになると考えます。
とはいえ…
現実には分離したいこともあります。
- 複雑な画面 - 大量のコンポーネント間の協調動作が必須な画面
- 開発者のスキル
- 開発体制
どうするか
そもそも下記のご発表のようにHotwire自体の表現力が高いため、基本的にRails側で状態を管理しつつ#41の「Reactにしかできない複雑なUIならHotwireに埋め込めばいい」のアプローチを採ります。
実装方式
開発サイクル
Rails 7以降ではimportmapによりESModules(ESM)を導入できます。またViteではESMをビルドできます。
そのためReactコンポーネントESMにビルドしてRailsへ導入、Stimulusから利用する方式とします。
状態管理
ReactコンポーネントとRailsの通信はHotwireを経由させます。
バックエンド・フロントエンド間のデータ搬送をRailsに任せることで前述の課題を回避しつつ、ReactはUIに集中します。
注意点
RailsとReactの両方で利用するモジュールは、それぞれの環境にバージョンを合わせて導入する必要があります。
今回はReactとTailwindCSSを導入します。
アプリケーションの構築
ここからはRails・Viteアプリの作成と設定について記載します。
前提
- Windows 11 / WSL (Ubuntu-22.04)
- Ruby 3.4.7
- Node.js 24.10.0
gem install rails
rails -v # => 8.1.0
Rails、Viteアプリの作成
最終的に次のようなディレクトリ構成となります。
islands-on-rails/
+ app/ # Railsアプリの実装
+ assets/tailwind/application.css # CSS(TailwindCSS) ※ Rails+React
+ javascript/controllers/*.js # JavaScript(Stimulus)
+ views/**/*.html.erb # HTML(ERB)
+ ...
+ shared-ui/
+ src/ # Viteアプリの実装
+ index.css # CSS(TailwindCSS) ※ Reactのみ
+ index.ts # Railsへのエクスポート
+ **/*.tsx # Reactコンポーネント
RailsおよびViteのCLIツールによりアプリを作成します。
rails new islands-on-rails --css tailwind
# create
# create README.md
#
# ...
#
# create db/cable_schema.rb
# force config/cable.yml
cd islands-on-rails
npm create vite@latest
# > npx
# > "create-vite"
#
# ◇ Project name:
# │ shared-ui
# ◇ Select a framework:
# │ React
# ◇ Select a variant:
# │ TypeScript
# ◇ Use rolldown-vite (Experimental)?:
# │ No
# ◇ Install with npm and start now?
# │ No
cd shared-ui
npm install
# added 237 packages, and audited 238 packages in 4s
#
# 47 packages are looking for funding
# run `npm fund` for details
#
# found 0 vulnerabilities
cd ..
ViteのLibrary Mode設定
npm run build でESMを生成できるようにします。
-
shared-ui/vite.config.jsexport default defineConfig({ plugins: [react()], + define: { + "process.env": { + NODE_ENV: process.env.NODE_ENV, + }, + }, + build: { + lib: { + entry: "src/index.ts", + name: "shared-ui", + fileName: "shared-ui", + formats: ["es"], + }, + rollupOptions: { + external: ["react", "react-dom"], + }, + minify: "esbuild", + target: "es2020", + }, }) -
shared-ui/src/index.ts/* Railsに公開するコンポーネントはここにエクスポートします。 * (デフォルトのmain.tsxはViteのみでの動作確認用とします) */
Railsからの参照設定
-
Procfile.dev(開発用)注:開発中にはReactコンポーネントを変更した場合、Rails側に反映するにはRails側のJavaScriptを保存したうえで画面をリロードする必要があります。
- web: bin/rails server + web: bin/rails server -b 0.0.0.0 css: bin/rails tailwindcss:watch + shared-ui: cd shared-ui && npm run dev + shared-ui-css: cd shared-ui && npm run build -- --watch -
lib/tasks/custom_assets.rake(本番用)Railsの本番モードでは
rails assets:precompileするため、その処理前にESMをビルドするようにします。namespace :custom_assets do desc "Build shared-ui package" task :build_shared_ui do system("cd shared-ui && npm run build") end end # 一般的なassets:precompileタスクの前にshared-uiをビルドします。 Rake::Task["assets:precompile"].enhance ["custom_assets:build_shared_ui"] -
app/assets/tailwind/application.cssRails側のTailwindCSSエントリポイントで、ViteのCSSを指定します。
@import "tailwindcss"; + @import "../../../shared-ui/src/index.css"; -
config/application.rbimportmapの参照先に、Viteアプリのビルド結果の出力先ディレクトリを追加します。
module IslandsOnRails class Application < Rails::Application ... + # React (Vite) components + config.assets.paths << Rails.root.join("shared-ui/dist") end end -
config/importmap.rb+ pin "shared-ui"
React/Railsの共通ライブラリの導入
片方にしかないライブラリのバージョン確認
-
React側に合わせるもの
cd shared-ui # React, React DOM npm list | grep react # react-dom@19.2.0 # react@19.2.0 -
Rails側に合わせるもの
# tailwindcss bundle list | grep tailwindcss-ruby # => 4.1.13
Reactへの共通ライブラリ導入
前の手順で確認したバージョンを指定します。
- tailwindcss : 4.1系
# refs https://tailwindcss.com/docs/installation/using-vite
cd shared-ui
npm install tailwindcss@~4.1.13 @tailwindcss/vite
-
shared-ui/vite.config.jsimport { defineConfig } from 'vite' import react from '@vitejs/plugin-react' + import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], -
shared-ui/src/index.cssTailwindCSSを導入するとともに、衝突を避けるためデフォルトのスタイルを削除します。
+ @import "tailwindcss"; - :root { - ... - } -
shared-ui/src/App.css -
shared-ui/src/App.tsx前述の理由によりデフォルトのスタイルを削除します。
- #root { - ... - }- import './App.css'
Railsへの共通ライブラリ導入
前の手順で確認したバージョンを指定します。
- react-dom : 19.2系
- react : 19.2系
./bin/importmap pin react-dom@~19.2.0/client react@~19.2.0
# Pinning "react" to vendor/javascript/react.js via download from https://ga.jspm.io/npm:react@19.2.0/index.js
# Pinning "react-dom/client" to vendor/javascript/react-dom/client.js via download from https://ga.jspm.io/npm:react-dom@19.2.0/client.js
# Pinning "react-dom" to vendor/javascript/react-dom.js via download from https://ga.jspm.io/npm:react-dom@19.2.0/index.js
# Pinning "scheduler" to vendor/javascript/scheduler.js via download from https://ga.jspm.io/npm:scheduler@0.27.0/index.js
(例)Reactコンポーネントの作成とRailsでの利用
今回は例として下記条件で確かめます。
- React :
.my-component-classおよび.text-[#ff00ff]のスタイルがRails側で反映されること - Rails : viewsで使われる
.my-component-classにReact側で定義されたスタイルが当たること - Rails : Railsでのみ利用される
.text-[#00ffff]にスタイルが反映されること
rails generate controller pages about
-
app/views/pages/about.html.erb- <div> + <div data-controller="my-component"> <h1 class="font-bold text-4xl">Pages#about</h1> - <p>Find me in app/views/pages/about.html.erb</p> + <p> + <span class="my-component-class">React (</span> + <input type="number" data-my-component-target="input" name="dummy" value="10" class="w-8" /> + <span class="text-[#00ffff]">) Rails</span> + </p> + <div data-my-component-target="button" class="contents">Loading...</div> </div> -
app/javascript/controllers/my_component_controller.jsimport { Controller } from "@hotwired/stimulus" import { createElement } from "react"; import { createRoot } from "react-dom/client"; import { Button } from "shared-ui"; export default class extends Controller { static targets = ["input", "button"]; connect() { this.root = createRoot(this.buttonTarget); this.inputTarget.addEventListener("input", this.render); this.render(); } disconnect() { this.inputTarget.removeEventListener("input", this.render); this.root.unmount(); } render = () => { // ※ function形式の場合は他の関数に渡すときに `this.render.bind(this)` で渡す必要があります。 const count = Number.parseInt(this.inputTarget.value); this.root.render(createElement(Button, { count, onChangeCount: this.handleChangeCount })); }; handleChangeCount = (count) => { this.inputTarget.value = count; this.render(); }; } -
shared-ui/src/Button.tsximport { type FC } from "react"; interface ButtonProps { count: number; onChangeCount: (count: number) => void; } export const Button: FC<ButtonProps> = (props) => { const { count, onChangeCount } = props; return ( <button onClick={() => onChangeCount(count + 1)}> <span className="my-component-class">Vite (</span> {count} <span className="text-[#ff00ff]">) React</span> </button> ); }; -
shared-ui/src/index.css@import "tailwindcss"; + + @layer components { + .my-component-class { + @apply text-blue-500; + } + } -
shared-ui/src/index.tsexport { Button } from './Button'; -
shared-ui/src/App.tsx+ import { Button } from './Button'; ... <div className="card"> + <Button count={count} onChangeCount={setCount} /> <button onClick={() => setCount((count) => count + 1)}> count is {count} </button>
動作確認
サーバを起動して、Reactコンポーネント・Rails画面それぞれで動作確認します。
コンポーネントのデザインは http://localhost:5173/ 、Railsアプリとの統合は http://localhost:3000/ で確認してください。
./bin/dev
# React => http://localhost:5173/
# Rails => http://localhost:3000/
おわりに
今回は、標準のRailsを活かしつつ、フロントエンドフレームワークとしてReactを導入する方法を紹介しました。
今後は、Turboを活かしたサーバからのプッシュ更新なども執筆したいと思います。



