Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Inertia.jsでRailsのJavaScript開発にシンプルさを取り戻そう(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。なお、Inertia.jsはLaravelなど他のフレームワークでも使われています。

inertiajs/inertia-rails - GitHub

参考: Inertia.jsを使用したモダンなSPA開発
参考: Inertia.jsがなぜ使いやすいのかをなるべく丁寧に言語化する #React - Qiita

Rails: Inertia.jsでRailsのJavaScript開発にシンプルさを取り戻そう(翻訳)

はじめに

フロントエンド開発周りの騒ぎは、何年経っても一向に止みそうにありません。どこの開発会社を覗いてみても、目にするのは同じような悲しい出来事ばかりです。優秀な開発者たちが必要もないツールの山で溺れそうになりながら問題を解決しようと悪戦苦闘してます。しかもそれは、誰かさんが売り込んできたソリューションを導入するまでは起きていなかったはずの問題なのです。

私もこの業界に長くいるので、パターンはあらかた心得ています。まず、React、Vue、Angular、Svelteの中からお好きなものをお選びくださいと持ちかけて、エコシステムを分断します。そして、こちらの頭がぼうっとするほど選択肢を増やしに増やしまくります。そして最後に、元の問題より高くつく「専門家によるソリューション」にたどりつくという流れです。

Railsコミュニティはまさにその実例です。DHHは長年の間複雑さと戦い、「No Build」をモットーとしてHotwireをリリースしました。しかしRails開発者の多くは考えを改めています。

2024年度のRails Community Surveyで、この問題の深さがあらわになりました。Stimulusを使っているRails開発者はわずか31%でした。その他多くの開発者たちは、Rails API + SPAアーキテクチャが「モダン」であり「必要」であると確信しています。

これは進歩ではなく、開発者たちがRailsの中核となる強みである「サーバーサイドのシンプルさ」を放棄して、ユーザーにとって何の価値もない「クライアントサイドの複雑さ」を選んでいるということです。

この移行に要するコストは測定可能であり、しかも重大です。

🔗 「モダンな」フロントエンドアーキテクチャの本当のコストとは

Rails開発者がAPI + SPAパターンを採用する場合、単にツールを乗り換えれば済むような話では終わりません。以下のような根本的な複雑さを受け入れることになるのです。

  • コードベースが二重化する: コーディング規約もテスト方法もデプロイパイプラインもまるで異なる
  • シリアライズのオーバーヘッド: RailsオブジェクトをJSONに変換し、再びJavaScriptオブジェクトに戻すことになる
  • ロジックが重複する: ルーティングもバリデーションもビジネスルールも両側にまたがって存在することになる
  • 認証が複雑になる: トークン管理、リフレッシュのロジック、セキュリティ境界が複雑になる
  • ステートの同期: クライアントとサーバーのデータを一貫させる必要がある

これらによって継続的に生み出されるメンテナンスの負担は、ユーザーにとって何の価値もありません。

しかし1つ納得できない点があります。モダンなフロントエンド開発でこれらの複雑さがすべて必要不可欠だというのであれば、Inertia.jsが5年の長きに渡ってこうした問題を次々に解決してきたというのに、どうしてそのことがRailsコミュニティでほとんど注目されてこなかったのでしょうか?

🔗 Inertia.jsという新しいアプローチ

inertiajs/inertia - GitHub

Inertia.jsは、APIレイヤを完全に不要にします。フロントエンドアプリケーションとバックエンドアプリケーションを別個に構築するのではなく、UIにJavaScriptコンポーネントを用いる由緒正しいサーバーサイドアプリを構築できます。

Railsコントローラは、JavaScriptコンポーネントにデータを直接返します。JSON APIも、クライアントサイドのルーティングも、ステート管理ライブラリも、一切不要です。

参考: Inertia.js in Rails: a new era of effortless integration

Inertia.jsは、以下の不必要な複雑さをシステマチックに排除します。

  1. APIレイヤを排除: Railsコントローラはデータを直接コンポーネントに返すだけでよい
  2. クライアントサイドルーティングを排除: Railsのルーティングだけですべてをまかなえる
  3. ステート管理の複雑さを排除: ReduxやZustandやContext APIは不要
  4. Railsの規約を損なわない: サーバーサイドロジックはそのまま残る

しかし、そんなものは表面的な話に過ぎません。真の証拠は、これらのパターンがどのようにスケールするかに現れるのです。

🔗 高度な技術

Inertia.jsの基本的なセットアップ方法については2024年の記事で既に解説しました。このときは、Inertia.jsでRailsアプリと統合されたReactアプリケーションを構築しました。
今回は、より高度な技術を探求して、このアプローチを支えている哲学に深く迫っていきたいと思います

Inertia.jsを深く掘れば掘るほど、Inertia.jsが単なる「使いやすいSPA」にとどまらない凄みを帯びていることがわかってきます。従来のあの複雑さはそもそも本当に必要だったのだろうかという気持ちにすらなってきます。

🔗 ルーター不要のナビゲーション

面白くなるのはここからです。クライアントサイドルーターのことを一切学ばずに、SPAスタイルのナビゲーションを実現できるのです。
Inertia.jsはHotwireのようなマジックではなく、明示的なリンクを使うのですが、その方法はいたってシンプルです。

// app/javascript/components/PostPreview.jsx
import { Link } from "@inertiajs/react"

export const PostPreview = ({ post }) => (
  <div>
    <h2>{post.title}</h2>
    <Link href={`/posts/${post.id}`}>
      Show this post
    </Link>
  </div>
)

ルーティングを/posts/${post.id}のように文字列URLで参照していますが、これはクライアントサイドルーティングを実装しているという意味ではありません。<Link>コンポーネントがこのクリックをインターセプトして、ページ全体をリロードする代わりにAJAXリクエストをRailsルーティングに送信します。Railsのルーティングファイルは、単一の真の情報源として機能します。これによって、クライアントサイドナビゲーションの高パフォーマンスと、サーバーサイドルーティングのシンプルさを両立させています。

JsRoutesを使うと、Railsの名前付きルーティングで利用できるJavaScriptヘルパーを自動的に生成して、フロントエンドコードでURLをハードコードせずに、postPath(post.id)のように直接メソッドを呼び出せるようになります。

外部のURLやInertia以外のページにリンクする必要があれば、標準の<a>タグを使うだけで済みます。

// app/javascript/components/HeadLinks.jsx
import { Link } from "@inertiajs/react"

export const HeadLinks = () => (
  <nav>
    <Link href="/">Home</Link>
    <a href="/log_in">Log in using Devise</a>
    <a href="https://github.com/evilmartians">GitHub</a>
  </nav>
)

パターンは明らかになりました。この問題が何年も前にRailsで解決済みなのに、わざわざフレームワークごとにルーティングシステムを学ぶ必要があるでしょうか?

🔗 部分リロード: 遅延読み込みを解放する

データを読み込むために、クライアントサイドの複雑なデータフェッチライブラリを使う必要はもうありません。Inertia.jsの部分リロード(partial reload)機能によって、モダンなフロントエンドにつきまとっていた複雑さの大半が実は不要だったことが暴露されました。

1件のブログ記事(post)に数百件のコメントが付く可能性がある場合を考えてみましょう。コメント全件を最初に読み込むのは無駄ですが、ユーザーがコメントに手軽にアクセス可能にしておく必要があります。

class PostsController < ApplicationController
  def show
    render inertia: {
      post: serialize_post(@post),
      comments: InertiaRails.optional do
        serialize_comments(@post.comments.includes(:user))
      end
    }
  end
end

記事コンテンツだけであれば、ページは瞬時に読み込まれます。しかし高負荷のコメントシリアライズは、ユーザーが明示的に要求するまで実行されません。

// app/javascript/pages/posts/show.jsx
import { Link } from "@inertiajs/react"
import { CommentsList } from "@/components/CommentsList"

export default functionPostShow({ post, comments }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {comments === undefined ? (
        <Link only={["comments"]}>Load Comments</Link>
      ) : (
        <CommentsList comments={comments} />
      )}
    </div>
  )
}

「Load Comments」をクリックすると、同じURLに対して新しいリクエストが送信されますが、Inertia.jsが返すレスポンスにはコメントデータだけが含まれています。属性を変更すると、リロードがトリガーされます。
Inertia.jsは、「リクエストの処理」「特定データの更新」「コンポーネントの再レンダリング」を行います。

Inertia.jsの部分リロード機能は、計算コストの高いデータや、遅延読み込み可能なウィジェット、ページネーション、検索フィルタなど、ユーザーがすぐに利用しない可能性のある2次コンテンツを扱うのに最適です。

Inertia.js 2.0では、propの先送り(defer)propのマージでさらに細かな制御が可能です。

🔗 共有データでグローバルなステートの複雑さを根絶する

開発者たちは、グローバルなステートを扱うにはReduxやZustandやContext APIが不可欠だと思いこんできました。Inertia.jsの共有プロパティ(shared property)は、それが思い込みであったことを明らかにしました。

class ApplicationController < ActionController::Base
  inertia_share flash: -> { flash.to_hash },
                current_user: -> { current_user&.as_json(...) },
                feature_flags: -> { FeatureFlags.all.as_json(...) }
end

これで、Inertia.jsのあらゆるレスポンスにこのデータが自動的に含まれるようになります。サーバーサイドのデータは、自動的にあらゆるコンポーネントに流れ込みます。つまり、ERBテンプレートで常にそうだったように、current_userは常にInertia.jsのレスポンスに含まれます。

export default function Header({ current_user }) {
  return <div>Welcome, {current_user.name}!</div>
}

このシンプルさによって、世にあるステート管理ライブラリのほとんどが実は不要だったことが明らかになりました。

🔗 フォーム: Railsの規約を変えずにモダンなコンポーネントを利用する

SPAに移行しようと頑張ってきたRailsエンジニアたちの多くは、Railsのフォームのシンプルさを諦めるしかないと信じ込んでいました。SPAのために、バリデーションのカスタムロジックを書いて、フォームのステートを手動で管理し、サーバーサイドのバリデーションがクライアントサイドと重複するのは仕方のないことだと思っていたのです。

Inertia.jsのフォーム機能は、Railsのフォームとまったく同様に使えるうえに、モダンなJavaScriptコンポーネントも利用できます。

すっかりお馴染みのRailsフォームビルダーを思い出しましょう。

<%= render_form_for @password_reset_form do |form| %>
  <%= form.text_field :email %>
  <%= form.submit %>
<% end %>

Inertia.jsは、RailsフォームとモダンなJavaScriptコンポーネントが両立することを証明しています。以下は上と同じフォームをInertia.jsスタイルで書き換えたものです。

// app/javascript/components/CreateUserForm.jsx
import { useForm } from '@inertiajs/react'

export const CreateUserForm = ({ user }) => {
  const { data, setData, post, errors } = useForm({email: ""})

  return (
    <form onSubmit={e => { e.preventDefault(); post("/passwords") }}>
      <input type="text" value={data.email}
        onChange={(e) => setData("email", e.target.value)}
      />
      {errors.email && <div>{errors.email}</div>}
      <button type="submit">Reset Password</button>
    </form>
  )
}

Railsよりは少しばかり分量が増えますが、そこは我慢です。
バリデーションは、従来と同様Railsのモデル内で実行されます。

class User < ApplicationRecord
  validates :email, uniqueness: true, presence: true, format: URI::MailTo::EMAIL_REGEXP
end

バリデーションが失敗すると、Inertia.jsは自動的にエラーをコンポーネントに返します。設定の追加も、クライアントとサーバーのエラー対応付けも不要です。Railsの得意な処理をそのまま実行してくれます。


しかし本当に面白くなるのはここからです。

Railsが私たちにフォームの抽象化の使い方を教えてくれたように、Inertia.jsのコンポーネントでも同じことができるのです。

aviemet/useInertiaForm - GitHub

useInertiaFormという小さなライブラリは、Inertia.jsでのフォーム処理をシンプルにするためのReactフックを提供しています。これを使うことで、Railsのフォームビルダーと同じように再利用可能なフォームコンポーネントを作成できるようになります。

// app/javascript/components/CreateUserForm.jsx
import { Submit } from 'use-inertia-form'
import { Form, TextInput, DynamicInputs } from '@/components/form'

const CreateUserForm = ({ user }) => (
  <Form model="user" data={ {user} } to={'users'}>
    <TextInput name="email" label="Email" />

    <DynamicInputs model="socials" emptyData={ {link: ''} }>
      <TextInput name="link" label="Link" />
    </DynamicInputs>

    <Submit>Create User</Submit>
  </Form>
)

このパターンによって、フォームのエコシステム全体が不要になります。Railsのバリデーションは、従来通りモデルから動かさずに済みます。自作のJavaScriptコンポーネントが受け取るエラーは、Railsのフォームで受け取っていたエラーとまったく同じように受け取れます。

Laracon US 2025というLaravelのイベントで、Inertia.jsに新しい<Form>コンポーネントが導入されることがアナウンスされました。これで、フォームがさらに使いやすくなります。今後のアップデートにご期待ください!

🔗 モーダルダイアログ: ステートがなければ問題も起きない

複雑さといえば、モーダルダイアログのステート管理もありますね。本来シンプルなはずのモーダルダイアログを導入しようとした途端、ダイアログのオープン、クローズ、背景クリック、エスケープキーなどのためにモーダルダイアログ用のライブラリをインポートしてローカルのステートを管理するはめになります。

Inertia.jsのモーダルダイアログライブラリを使えば、バックエンドの設定を一切行わずに、そうした面倒なステート管理をカットしてくれます。

// app/javascript/layouts/posts.jsx
import { ModalLink } from '@inertiajs/modal'

export default function PostsLayout({ children }) {
  return (
    <div>
      <h1>Posts</h1>
      <ModalLink href="/posts/new">
        Create New Post
      </ModalLink>
      {children}
    </div>
  )
}

Railsコントローラ側では、モーダルダイアログが表示されることすら知る必要がありません。

class PostsController < ApplicationController
  def new
    @post = Post.new
    render inertia: { post: @post }
  end
end

表示されるページは、モーダルコンポーネント自体の中にラップされます。

// app/javascript/pages/posts/new.jsx
import { Modal } from '@inertiajs/modal'

export default function New({ post }) {
  return (
    <Modal>
      <h1>Create New Post</h1>
      <PostForm post={post} />
    </Modal>
  )
}

ステート管理も、追加のモーダルダイアログライブラリも、コンフィグも、一切不要です。Railsコントローラのアクションは、そのままで通常のページ表示にもモーダルダイアログの表示にも使えます。

モーダルダイアログのさらに高度な欲しい場合は、Inertia Modal Cookbookを参照してください。ベースURLをサポートする形でモーダルダイアログを強化できます。

🔗 Action Cable: 複雑さと無縁なリアルタイム表示

リアルタイム機能といえば従来のSPAで強みとされてきましたが、多くの場合、まったく新しいライブラリの使い方やWebSocketの管理方法、ステート同期による複雑さなどがつきまとっていました。

Railsは既にAction CableでWebSocketに対応しており、Inertia.jsはHotwireと同じパターンでWebSocketとシームレスに動作します。

Action Cableのチャネルクラスの外見は、従来とまったく同じに見えます。

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_#{params[:room_id]}"
  end
end

# モデルからブロードキャストする
class Message < ApplicationRecord
  # コールバックは注意して使うことをおすすめしたいが、
  # これはHotwireのデモでもよく使われているパターン
  after_create_commit :broadcast_new_message

  private

  def broadcast_new_message
    # production環境では非同期に行われる必要がある
    ActionCable.server.broadcast("chat_#{chat_room_id}", {
      message: self.as_json,
    })
  end
end

違いは、フロントエンドでの更新方法にあります。Inertia.jsでは、HotwireのようにDOM要素を手動で更新するのではなく、使い慣れたパターンでInertia.jsのpropを更新することになります。

// app/javascript/channels/chat_channel.js

import { router } from "@inertiajs/react"
import { consumer } from "utils/cable"

// チャネルにサブスクライブする
const chatChannel = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    received(data) {
      switch(data.type) {
        case "message_created":
          this.handleNewMessage(data.message)
          break
      }
    },

    // オプション1: ページ全体をリロード(最もシンプル、どこでも利用可能)
    handleNewMessage() {
      router.reload()
    },

    // オプション2: 部分リロード(効率がよい)
    handleNewMessage() {
      router.reload({ only: ["messages"] })
    },

    // オプション3: propを直接更新(最も効率がよい)
    handleNewMessage(message) {
      router.replace({
        props: (current) => ({
          ...current,
          messages: [...current.messages, message]
        })
      })
    }
  }
)

Reactコンポーネントでは、更新されたデータをprop経由で自然に受け取れます。

Action CableでTypeScriptを使いたい方は、ぜひ@anycable/webをお試しください。TypeScript対応のAction Cableクライアントなど、さまざまな機能が提供されています。

// app/javascript/components/ChatRoom.jsx
import { MessageForm } from "@/components/MessageForm"

export default function ChatRoom({ messages, currentUser, room }) {
  return (
    <div className="chat-room">
      <div className="messages">
        {messages.map(message => (
          <div key={message.id} className="message">
            <strong>{message.user.name}:</strong> {message.content}
          </div>
        ))}
      </div>
      <MessageForm roomId={room.id} />
    </div>
  )
}

このアプローチのおかげで、リアルタイム機能がシンプルになり、Railsの規約とも調和します。新しいライブラリや複雑なステート管理を学ぶ必要はありません。使い慣れたAction Cableのパターンをそのまま利用できます。

🔗 TypeScript:「型にとらわれない」自動型付け

Next.jsやRemixのようなフルスタックJavaScriptフレームワークの強みの1つは、フロントエンドとバックエンドの両方にまたがる型安全を手に入れられることです。APIとフロントエンドで同じTypeScript定義を共有すれば、コンパイル時のデータ整合性が保証されます。

これは確かに大きなメリットではありますが、そのために何が犠牲になるかを考えると、そうも言っていられません。バックエンドをRailsからJavaScriptに移行すると、Railsの成熟したエコシステムや、現場での実績があるパターンや、長年養われてきたRailsの規約をみすみす捨てることになります。

しかし、Railsを使い続けながら、それと同様の型安全性が手に入るとしたらどうでしょうか?Typelizerは、まさにそのギャップを埋めてくれます。

skryukov/typelizer - GitHub

Typelizer gemは、RailsのシリアライザからTypeScript定義を自動生成します。これにより、Railsエコシステムを手放さずに、フロントエンドとバックエンドの型を統合できます。

実際の動作を以下で示します。
最初に、TypelizerのDSLとAlba(JSONシリアライザ)を使ってシリアライザを定義してみましょう。

class ApplicationResource
  include Alba::Resource
  helper Typelizer::DSL
end

class PostResource < ApplicationResource
  attributes :id, :title, :category, :body, :published_at
  attribute :author, resource: AuthorResource
end

class AuthorResource < ApplicationResource
  # 型を推測するモデルを指定する(オプション)
  typelize_from User

  attributes :id, :name

  # 仮想属性では、`typelize`メソッドで型を手動定義できる
  typelize :string, nullable: true
  attribute :avatar do
    "https://example.com/avatar.png" if active?
  end
end

Typelizerのジェネレータを実行します(あるいはdevサーバーを実行すればその場で型が自動生成されます)。

rails typelizer:generate

これで、シリアライザに完全一致する以下のTypeScriptインターフェイスが自動生成されます。

export interface Post {
  id: number;
  title: string;
  body: string;
  published_at: string | null;
  category: "news" | "article" | "blog" | null;
  author: Author;
}

export interface Author {
  id: number;
  name: string;
  avatar?: string | null;
}

Inertia.jsコンポーネントは、これを元に「型安全性」を自動的に実現します。

// app/javascript/pages/posts/show.tsx

import { Post } from "@/types"

interface Props {
  post: Post;
}

export default function Show({ post }: Props) {
  // TypeScriptはプロパティ名の間違いをキャッチできる
  const isPublished = post.published_at !== null
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author.name}</p>
      <div>{post.body}</div>
      {!isPublished && <Link href={post.edit_url}>Edit draft</Link>}
    </article>
  )
}

listen gemをGemfileに追加すれば、Railsのモデルやリソースを更新するたびに型も自動更新されます。フィールドを追加すると、フロントエンド側の型も自動で同期し、ページのリロードも不要です。

TypeScriptを使いたい人ばかりではないのは当然です。しかしこの方法なら、Railsを使い続けながら型の整合性を取り入れたいチームや、従来のRailsから移行したいチームが、Railsの規約を維持したままフロントエンドとバックエンドの型を同期できます。

🔗 まとめ: 正しい方法を選択しよう

Inertia.jsは、SPAアーキテクチャにつきものの複雑さを取り入れることなく、モダンなJavaScriptコンポーネントやクライアントサイドのインタラクティブな操作の導入を可能にするという特定の問題を解決します。

クライアントサイドで高度な対話的操作を行う必要がないのであれば、Hotwireは実にシンプルなソリューションであり、おそらく選択として正しいでしょう。
しかし、アプリケーションでリッチなJavaScriptコンポーネントや動的なインターフェイス、クライアントサイドの複雑なステート管理が求められるようになっても、「Railsの規約を捨てて複雑なアーキテクチャを受け入れるべき」というささやきに耳を貸してはいけません。

今やInertia-railsのエコシステムは勢いづいています。新しいスターターキットが登場し、積極的な開発が行われていて採用事例も増えており、RailsConfのキーノートスピーチでも力強く言及されています↓。

開発者たちは、Railsのシンプルさを犠牲にしなくてもモダンなフロントエンド機能を実現することを再発見しつつあるのです。

どんな複雑なアーキテクチャであっても、どんな抽象化レイヤの追加であっても、どんな「不可欠な」ツールであっても、それらを導入する前に、胸に手を当てて「これは本当に問題を解決してくれるのか?それとも問題をさらに増やすだけなのか?」と自問自答しましょう。

業務に適したツールを選ぶ準備ができたら、以下の手順で進めましょう。

  1. モダンなJavaScriptコンポーネントが必要になったら、まずはInertia.jsを試そう
  2. 試した結果を他の開発者と共有しよう
  3. 機能の導入が避けられないことがわかったら、それがどの程度まで複雑なのかを調べよう
  4. 自分たちのニーズに合う適切なツールを選ぼう
  5. 肝に銘じよう: 複雑さは問題を解決するためのものであり、問題を増やすものであってはならない

Inertia.jsは、複雑なアーキテクチャを使わなくてもモダンなフロントエンド開発が可能であることを証明しています。RailsでJavaScriptコンポーネントが必要になっても、Railsの規約を放棄する必要もなければ、SPAのオーバーヘッドを受け入れる必要もありません。

選択肢はいたってシンプルです。「モダンだから」という理由で複雑なものを取り入れてしまうか、問題を増やさずに問題を実際に解決できるツールを選ぶか、2つに1つです。

関連記事

Evil Martiansが贈る「古いRailsアプリを1日1時間✕12日でリフレッシュする方法」

Rails: Evil Martiansが使って選び抜いた夢のgem -- 2024年度版(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。