EY-Office ブログ

React嫌いのRailsプログラマーはまずInertia.jsを使うべきだと思います

元々のタイトル「最近のRuby on RailsのReact開発・統合環境を使ってみたら素晴らしかったvite_rails, Inertia.js (2)」

最近のRuby on RailsのReact開発・統合環境を使ってみたら素晴らしかったvite_rails, Inertia.js (1)の続きです。
先週のvite_railsは素晴らしいReact(SPA)開発環境でしたが、今回のInertia.jsも素晴らしいライブラリーだと思いました。

vite_rails-inertia Gemini 2.5 Flash Imageが生成した画像をもとにしています

Inertia.jsとは

Inertia.jsはRuby on Railsのようなフレームワークにフロントエンド(SPA)をシームレスに接続するライブラリーです。
PHPのフレームワークLaravelで活発に利用されていますが、Ruby on RilasやDjangoでも利用されています。

ちなみに、inertiaの意味は慣性、惰性、惰力、不活発、ものぐさ、遅鈍、無力(症)、緩慢だそうです(Weblio英和辞書より)。イナーシャという単語は物理学や機械工学の世界では使われていますよね。

Ruby on Rails用のライブラリーはInertia.js Railsです。トップページに書かれて言葉を要約すると

  • SPAをAPI通信なしで作れる
  • React, Vue, SvelteなどモダンなSPAが使える
  • サーバーサイドのルーティングが使える

まずは結論から

Inertia.js良さを見てもらうには、先週のコードとInertia.jsを使ったReactのコードを比べて見るのが早いと思います。

先週のコード

app/javascript/pages/App.tsx

先週のコードにはReact(SPA)らしく、以下のような処理が書かれています。

  • ① Railsと通信すためのコード
  • ② Railsから受け取ったデータを管理するステート管理
  • ③ 画面起動時にのみデータを取得を行うための同期処理(ライフサイクル管理)
  • ④ 入力フィールドでキー入力されたデータのステート管理
  • ⑤ 入力フィールドのキー入力イベントの処理

面倒ですね。さらに、このコードには以下がありません。😅

  • CORS対策、公開するアプリではCORS対策は必須ですね
  • Rails側で入力データのチェックを行ったさいのエラーメッセージの表示機能

また、複雑なアプリになれば機能とURLを対応付けるルーティング機能なども必要になります、さらに面倒になりますね。

import { useEffect, useState } from 'react';

type BookType = {
  id: number
  date: string
  description: string
  money_in: number|null
  money_out: number|null
};
type BookCreateType = Omit<BookType, 'id'>;

const SERVER_URL = 'http://localhost:3000';

const getAllBooks = async (): Promise<BookType[]> => {        // ← ①
  const response = await fetch(`${SERVER_URL}/books.json`)
  if (!response.ok) {
    throw new Error('Failed to fetch books')
  }
  return response.json()
}

const deleteBook = async (bookId: number): Promise<void> => {
  const response = await fetch(`${SERVER_URL}/books/${bookId}`, {
    method: 'DELETE',
  })
  if (!response.ok) {
    throw new Error('Failed to delete book')
  }
}

const PostBook = async (book: BookCreateType): Promise<void> => {
  const response = await fetch(`${SERVER_URL}/books`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ book }),
  })
  if (!response.ok) {
    throw new Error('Failed to create book')
  }
}

function App() {
  const [books, setBooks] = useState<BookType[]>([])          // ← ②

  useEffect(() => {                                           // ← ③
    (async() => {
      setBooks(await getAllBooks())
    })()
  }, []) ;

  return (
    <div className="mx-2 md:mx-8 md:w-1/2">
      <h1 className="my-6 text-center text-2xl font-bold">小遣い帳</h1>
      <AllowanceBookList books={books} onDeleteBook={async(bookId:number) => {
        await deleteBook(bookId);
        setBooks(await getAllBooks());
      }} />
      <AllowanceBookEntry onAddBook={async(newBook: BookCreateType) => {
        await PostBook(newBook);
        setBooks(await getAllBooks());
      }} />
    </div>
  )
}
export default App;

function AllowanceBookList({ books, onDeleteBook }:
    { books: BookType[], onDeleteBook: (bookId: number) => void }) {
  return (
       <table className="mt-4 min-w-full divide-y divide-gray-200 shadow-sm rounded-lg overflow-hidden">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日付</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">入金</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">出金</th>
            <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {books.map((book) => (
            <tr key={book .id}>
              <td className="px-6 py-4 whitespace-nowrap">{book.date}</td>
              <td className="px-6 py-4 whitespace-nowrap">{book.description}</td>
              <td className="px-6 py-4 whitespace-nowrap text-right">{book.money_in || ''}</td>
              <td className="px-6 py-4 whitespace-nowrap text-right">{book.money_out || ''}</td>
              <td>
                <DeleteButton onClick={() => onDeleteBook(book.id)} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
  );
}

function AllowanceBookEntry({ onAddBook }:
    { onAddBook: (newBook: BookCreateType) => void }) {
  const [date, setDate] = useState('');                         // ← ④
  const [description, setDescription] = useState('');
  const [moneyIn, setMoneyIn] = useState('');
  const [moneyOut, setMoneyOut] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newBook: BookCreateType = {
      date,
      description,
      money_in: moneyIn ? parseInt(moneyIn) : null,
      money_out: moneyOut ? parseInt(moneyOut) : null,
    };
    onAddBook(newBook);
    setDate('');
    setDescription('');
    setMoneyIn('');
    setMoneyOut('');
  };

  return (
    <form onSubmit={handleSubmit} className="my-6 p-6 bg-white shadow-md rounded-lg">
      <h2 className="text-xl font-bold mb-4">新しい項目を追加</h2>
      <div className="space-y-4">
        <div>
          <label htmlFor="date" className="block text-sm font-medium text-gray-700">日付</label>
                                                    // ↓ ⑤
          <input type="date" id="date" value={date} onChange={(e) => setDate(e.target.value)} required
            className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
        </div>
        <div>
          <label htmlFor="description" className="block text-sm font-medium text-gray-700">内容</label>
          <input type="text" id="description" value={description} onChange={(e) => setDescription(e.target.value)} required
            className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
        </div>
        <div>
          <label htmlFor="moneyIn" className="block text-sm font-medium text-gray-700">入金</label>
          <input type="number" id="moneyIn" value={moneyIn} onChange={(e) => setMoneyIn(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
        </div>
        <div>
          <label htmlFor="moneyOut" className="block text-sm font-medium text-gray-700">出金</label>
          <input type="number" id="moneyOut" value={moneyOut} onChange={(e) => setMoneyOut(e.target.value)} className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
        </div>
        <div className="mt-6 text-right">
          <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
            追加
          </button>
        </div>
      </div>
    </form>
  );
}

function DeleteButton({ onClick }: { onClick: () => void }) {
  return (
    <button type="button" onClick={onClick} className="px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded hover:bg-red-800">
      削除
    </button>
  );
}

Inertia.jsを使ったコード

app/javascript/pages/App.tsx

Inertia.jsを使ったReactコンポーネントのコードには、通信、ステート管理、同期処理は在りません。❗
さらに、CORS対応は出来ていますし、エラーメッセージを表示する機能が追加されています。❗❗

その秘密は、

  • ① ReactコンポーネントはRailsから起動され、RailsのデータはReactコンポーネントのProps(引数)に渡ってきます
  • ② フォームにはInertia.jsが提供する<Form>タグがあり、雑用をこなしてくれます
    • 入力値のステート管理は不要です
    • Rails側でのバリデーション結果(エラーメッセージ)を簡単に取り込めます
    • resetOnSuccessはFormからRailsに送ったデータの戻り値が成功ならフォームを空データにするオプションです、便利❗
import { Form } from '@inertiajs/react';

type BookType = {
  id: number
  date: string
  description: string
  money_in: number|null
  money_out: number|null
};

function App({books}: {books: BookType[]}) {                  // ← ①
  return (
    <div className="mx-2 md:mx-8 md:w-1/2">
      <h1 className="my-6 text-center text-2xl font-bold">小遣い帳</h1>
      <AllowanceBookList books={books} />
      <AllowanceBookEntry />
    </div>
  )
}
export default App

function AllowanceBookList({ books, }: { books: BookType[]}) {
  return (
    <table className="mt-4 min-w-full divide-y divide-gray-200 shadow-sm rounded-lg overflow-hidden">
      <thead className="bg-gray-50">
        <tr>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日付</th>
          <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
          <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">入金</th>
          <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">出金</th>
          <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
        </tr>
      </thead>
      <tbody className="bg-white divide-y divide-gray-200">
        {books.map((book) => (
          <tr key={book .id}>
            <td className="px-6 py-4 whitespace-nowrap">{book.date}</td>
            <td className="px-6 py-4 whitespace-nowrap">{book.description}</td>
            <td className="px-6 py-4 whitespace-nowrap text-right">{book.money_in || ''}</td>
            <td className="px-6 py-4 whitespace-nowrap text-right">{book.money_out || ''}</td>
            <td>
              <DeleteButton bookId={book.id} />
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function AllowanceBookEntry() {
  return (
    <Form action="/books" method="post" resetOnSuccess          /* ← ② */
      className="my-6 p-6 bg-white shadow-md rounded-lg">
      {({errors}) => (
        <>
          <h2 className="text-xl font-bold mb-4">新しい項目を追加</h2>
          <div className="space-y-4">
            <div>
              <label htmlFor="date" className="block text-sm font-medium text-gray-700">日付</label>
              <input type="date" id="date" name="book.date"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
              {errors.date && <ErrorMessage message={errors.date} />}
            </div>
            <div>
              <label htmlFor="description" className="block text-sm font-medium text-gray-700">内容</label>
              <input type="text" id="description"  name="book.description"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
              {errors.description && <ErrorMessage message={errors.description} />}
            </div>
            <div>
              <label htmlFor="moneyIn" className="block text-sm font-medium text-gray-700">入金</label>
              <input type="number" id="moneyIn" name="book.money_in"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
              {errors.money_in && <ErrorMessage message={errors.money_in} />}
            </div>
            <div>
              <label htmlFor="moneyOut" className="block text-sm font-medium text-gray-700">出金</label>
              <input type="number" id="moneyOut" name="book.money_out"
                className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
              {errors.money_out && <ErrorMessage message={errors.money_out} />}
            </div>
            <div className="mt-6 text-right">
              <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                追加
              </button>
            </div>
          </div>
        </>
      )}
    </Form>
  );
}

function ErrorMessage({ message }: { message: string }) {
  return (
    <div className="px-4 py-3 my-1 text-sm text-yellow-800 rounded-lg bg-yellow-50" role="alert">
      {message}
    </div>
  );
}

function DeleteButton({ bookId }: { bookId: number }) {
  return (
    <Form action={`/books/${bookId}`} method="delete">     // ← ②
      <button type="submit" className="px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded hover:bg-red-800">
        削除
      </button>
    </Form>
  );
}

★ ちなみに<Form>タグを使わずに、通常のReactのフォームでも以下のようにInertia.jsのuseFormフックで使えます。

function AllowanceBookEntry() {
  const initialFormData = { date: "", description: "",
                            money_in: "", money_out: "" };
  const { data, setData, post, errors } = useForm(initialFormData);

  useEffect(() => {    // 再表示時にバリデーション・エラーが無ければ入力値をリセットする
    if (Object.keys(errors).length === 0) {
      setData(initialFormData);
    }
  }, [errors]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    post('/books');     // RailsへPOST
  };

  return (
    <form onSubmit={handleSubmit} className="my-6 p-6 bg-white shadow-md rounded-lg">
      <h2 className="text-xl font-bold mb-4">新しい項目を追加</h2>
      <div className="space-y-4">
        <div>
          <label htmlFor="date" className="block text-sm font-medium text-gray-700">日付</label>
          <input type="date" id="date" value={data.date} onChange={(e) => setData("date", e.target.value)}
            className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
          {errors.date && <ErrorMessage message={errors.date} />}
        </div>

        ・・・
        </div>
      </div>
    </form>
  );
}
app/controllers/books_controller.rb

Ruby on Rails側もRails wayで拡張できます。

  • ① 独自のレンダーrender inertia:を使い指定したReactコンポーネントを表示できます
    • その際にReactコンポーネントのProps:にRailsのデータが渡せます
    • Rails風にコントローラーのクラス名、メソッド名とReactコンポーネントの対応を規約を使うこともできます → ドキュメント
    • やはりRails風にコントローラーのインスタンス変数を自動的にコンポーネントのPropsに渡すこともできます → ドキュメント
  • ② Rails側でのバリデーション結果(エラーメッセージ)を簡単にReactに渡せます
class BooksController < ApplicationController
  before_action :set_book, only: %i[ show edit update destroy ]

  # GET /books or /books.json
  def index
    render inertia: 'App', props: {                              # ← ①
      books: Book.all,
  }
  end

  ・・・

  # POST /books or /books.json
  def create
    @book = Book.new(book_params)

    if @book.save
      redirect_to books_path, notice: "Book was successfully created."
    else
      redirect_to books_path, inertia: inertia_errors(@book)     # ← ②
    end

  end

  ・・・

  # DELETE /books/1 or /books/1.json
  def destroy
    @book.destroy!
    redirect_to books_path, notice: "Book was successfully destroyed."
  end

  private

    ・・・

    def inertia_errors(model)                                   # ← ②
      {errors: model.errors.to_hash(false).transform_values(&:first)}
  end
end
app/models/book.rb

モデルにバリデーションを追加しました。エラーメッセージはコードに直書きしましたが、本来はconfig/locales/ja.yml等に書くべきでしょうね。

class Book < ApplicationRecord
  validates :date, presence: {message: "日付を入力してください"}, format: { with: /\A\d{4}-\d{2}-\d{2}\z/, message: "正しい日付を入力してください" }
  validates :description, presence: {message: "内容を入力してください"}
  validates :money_in, numericality: true, allow_blank: {message: "入金は数値で入力してください"}
  validates :money_out, numericality: true, allow_blank: {message: "出金は数値で入力してください"}
  validate :money_in_or_money_out_present

  private

  def money_in_or_money_out_present
    if (money_in.blank? && money_out.blank?) || (money_in.present? && money_out.present?)
      errors.add(:money_in, "入金または出金のいずれかを入力してください")
    end
  end
end

導入手順

最初のInertia.jsの導入はVite(React)が知っている人のサポートが必要かと思いますが、それほど難しくはないと思います。

1. インストール

$ bundle add inertia_rails
$ bin/rails generate inertia:install

inertia:installは設定ファイル等を作ってくれます、またフロントエンド用のnpmライブラリーもインストールしてくれます。

ただし、今回のようにすでにReactプロジェクトがあると設定ファイルのコンフリクト(conflict)が発生する事があります。他のRailsのgenerate同様にコンフリクトが発生するとYnaqdhmの選択問合せがありますが今回はGit管理しているので、全てYにしました。
その結果、ReactやTailwindCSSのバージョンが少し下がったりしました。

公式ドキュメント

2. Inertia.jsの動作確認

bin/devも書き変わりReact用開発サーバーとRails用サーバーが同時に起動されるようになります。またinertia:installはInertia.jsの動作確認用のRailsコントローラーとReactアプリを作成します。

$ bin/dev
15:23:59 vite.1 | started with pid 72208
15:23:59 web.1  | started with pid 72209
15:24:00 web.1  | => Booting Puma
15:24:00 web.1  | => Rails 8.0.3 application starting in development
15:24:00 web.1  | => Run `bin/rails server --help` for more startup options
15:24:00 web.1  | Puma starting in single mode...

  ・・・

15:24:00 vite.1 |   VITE v7.1.12  ready in 264 ms
15:24:00 vite.1 |
15:24:00 vite.1 |   ➜  Local:   http://localhost:3036/vite-dev/
15:24:00 vite.1 |   ➜  press h + enter to show help

ここで、http://localhost:3100/inertia-exampleをアクセスすると確認ページが表示されます。

今回の場合、エラーが発生して表示されませんでした。いろいろと試した結果 app/views/layouts/application.html.erbvite_タグの順番を変えることで確認ページが表示されるようになりました。

<!DOCTYPE html>
<html>
  <head>
    <title inertia><%= content_for(:title) || "Allowance Book" %></title>

    ・・・

    <%= vite_stylesheet_tag "application" %>
    <%= vite_react_refresh_tag %>
    <%= vite_client_tag %>
    <%= vite_typescript_tag "inertia" %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

3. 構成変更

Reactのディレクトリー構成をInertia.jsに合わせて変更しました。また不要になったmain.tsxは削除しました。

├── app
│   ├── javascript
│   │   ├── entrypoints
│   │   │   └── inertia.ts            ← Inertia.jsのエントリーポイント
│   │   └── pages                     ← srcからリネーム
│   │       ├── App.tsx               ← Reactコンポーネント
│   │       └── application.css       ← index.cssからリネーム

4. App.tsx, books_controller.rbの変更

先週のアプリから徐々にInertia.js用コードに変更していきました。

  • API通信やステート管理などをInertia.js風に変更
  • book.rbにバリデーションのコードを追加、App.tsxにエラーメッセージ表示コードを追加
  • 先週のコードにあったCSRFチェック無効化コードを削除
  • 最後に、Inertia.js動作確認用のコントローラーとReactコードを削除しました

まとめ

Inertia.jsは、Rails wayでReact等のSPAを導入できるライブラリーです。今回のコードではReact初心者がつまずきやすいステート管理や同期処理(ライフサイクル管理)、通信を書かずにReactを使ったフロントエンドが動かせました。😄

ここで「このアプリならRuby on Railsだけで作れば良いのでは?」と思った方。鋭いですね❗ たしかに今回のアプリならTailwindCSSを導入するだけで通常のRailsのView(.erb等)でも作れますよね。
しかし、データ入力時にリアルタイムでバリデーションを行なう機能を追加したり、入力によってダイナミックに変化するページはReactのフロントエンドなら簡単に作れます。

従来のRailsをAPIサーバーとして使い、Reactでフロントエンドを作るには全てのページをReactで作る必要がありました。さらにReact側でルーティングする必要も出てきます。
しかしInertia.jsを使えば、リアルタイム性やダイナミックに変化するページのみReactで作り、その他のページはRailsのViewで作る事ができます。

Rails wayでReact等のフロントエンドを含むアプリを開発できるのが、Inertia.jsの素晴らしい点だと私は思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ