0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby on RailsへのReactコンポーネントの統合

Posted at

はじめに

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から利用する方式とします。

image.png

状態管理

ReactコンポーネントとRailsの通信はHotwireを経由させます。
バックエンド・フロントエンド間のデータ搬送をRailsに任せることで前述の課題を回避しつつ、ReactはUIに集中します。

image.png

注意点

RailsとReactの両方で利用するモジュールは、それぞれの環境にバージョンを合わせて導入する必要があります。
今回はReactとTailwindCSSを導入します。

image.png

アプリケーションの構築

ここからは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.js

      export 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.css

    Rails側のTailwindCSSエントリポイントで、ViteのCSSを指定します。

      @import "tailwindcss";
    + @import "../../../shared-ui/src/index.css";
    
  • config/application.rb

    importmapの参照先に、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.js

      import { 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.css

    TailwindCSSを導入するとともに、衝突を避けるためデフォルトのスタイルを削除します。

    + @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.js

    import { 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.tsx

    import { 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.ts

    export { 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/

output.gif

おわりに

今回は、標準のRailsを活かしつつ、フロントエンドフレームワークとしてReactを導入する方法を紹介しました。
今後は、Turboを活かしたサーバからのプッシュ更新なども執筆したいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?