元々のタイトル「最近のRuby on RailsのReact開発・統合環境を使ってみたら素晴らしかったvite_rails, Inertia.js (2)」
最近のRuby on RailsのReact開発・統合環境を使ってみたら素晴らしかったvite_rails, Inertia.js (1)の続きです。
先週のvite_railsは素晴らしいReact(SPA)開発環境でしたが、今回のInertia.jsも素晴らしいライブラリーだと思いました。
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コンポーネントを表示できます - ② 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.erbのvite_タグの順番を変えることで確認ページが表示されるようになりました。
<!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の素晴らしい点だと私は思います。










