😫

モダンフロントエンドはJSON APIが鬱陶しいので、無くしていきたい

に公開

はじめに

Kaigi on Rails 2025で発表し、何人かの人といろいろ話しているうちに、モダンフロントエンドが面倒臭いのはJSON APIのせいではないかと考えるようになりました。そしてJSON APIそのものが悪いというよりは、JSON APIを必要以上に使う原因となっているSPAが問題ではないかと思っています。まだ考えは固まっていないのですが、まずは部分的に紹介したいと思います。

モダンフロントエンドはJSON基礎工事が大変

React vs Hotwire 工程比較

SPAのReactフロントエンドを作る場合、Hotwireなら不要だった多大な工数が新しく発生します

APIエンドポイントのルータおよびコントローラから、JSON APIシリアライザ、クライアントサイドのルータ、JSON APIをfetchしてフォーマット変換する作業、さらにAPIの契約を文書化したOpen APIを作成します。ここには記載していませんが、実際にはこれに加えてサーバ側でRSpecテストを書いてOpen API通りの形になっていることを確認したり、さらにクライアント側でTypeScriptの型を定義するでしょう。このReact UIに取り掛かる前のJSON基礎工事に非常に多くの工数がかかるのです。

具体的な比較

下記はdate pickerを使っている編集画面の例を取り上げます。Date pickerのライブラリとしてはERBバージョンではFlatpickr、React SPAバージョンではこれをラップしているreact-flatpickrを使用します。

なおコード例は実際に動作確認したものではなく、ChatGPTに生成させたものを目視でチェックしたものになります。ご了承ください。

予想通りではあるのですが、React SPAの方が随分とコード量が増えています。

Controllerレイヤー

下記の通り、React SPAの場合はcontrollerからJSONを返す必要があります。さらにフロントエンドと共有するためにOpen API (Swagger)のドキュメントも作ることになるでしょう。

またサーバが返すJSONが正しいことを証明するために、個々のフィールドの存在を確認するRSpecテストを書くことも多いかと思います。面白いことに、ERBを使っている場合はRSpecでフィールドをすべてチェックすることは滅多にやりません。個別のチェックを省略してもほとんどの場合は安全性が担保できるためです。[1]

React SPAの場合は、Open APIを作ることとRSpecテストを書くことが一番面倒と感じるのではないかと思います。

ERB

  def edit
    @booking = Booking.find(params[:id])
  end

React SPA

  def show
    @booking = Booking.find(params[:id])
    render json: {
      booking: {
        # インタラクティブ性は`check_in`だけ必要だが、
        # id, user_name, emailもJSONで送信している
        id: @booking.id,
        user_name: @booking.user_name,
        email: @booking.email,
        phone: @booking.phone,
        check_in: @booking.check_in
      }
    }
  end

React SPA: Open API

paths:
  /api/bookings/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    get:
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  booking:
                    type: object
                    properties:
                      # インタラクティブ性は`check_in`だけ必要だが、
                      # id, user_name, emailもJSONで送信している
                      id: { type: integer }
                      user_name: { type: string }
                      email: { type: string, format: email }
                      phone: { type: string }
                      check_in: { type: string, format: date }
                      created_at: { type: string, format: date-time }
                      updated_at: { type: string, format: date-time }
                    required: [id, user_name, email, phone, check_in]
                required: [booking]
              example:
                booking:
                  id: 42
                  user_name: "Jane Doe"
                  email: "jane@example.com"
                  phone: "+81-90-1234-5678"
                  check_in: "2025-10-12"
                  created_at: "2025-10-01T09:00:00Z"
                  updated_at: "2025-10-05T08:30:00Z"

RSpec

# spec/requests/api/bookings_spec.rb
require "rails_helper"

RSpec.describe "Bookings API", type: :request do
  describe "GET /api/bookings/:id" do
    let(:booking) { create(:booking) }

    it "returns all expected fields" do
      get "/api/bookings/#{booking.id}"

      json = JSON.parse(response.body)

      expect(json).to have_key("booking")

      booking_json = json["booking"]
      expect(booking_json.keys).to include(
        "id", "user_name", "email", "phone",
        "check_in", "created_at", "updated_at"
      )
    end
  end
end

Viewレイヤー

ERBの場合はテンプレートを記述するだけです。下記の例ではDOMContentLoadedイベントでflatpickr#check_inに接続していますが、HotwireであればStimulusを使うことになるでしょう(その場合はStimulus controllerのconnect()disconnect()メソッドを使うことになります)。

一方でReact SPAの場合は、useEffect()でまずJSON APIからデータをフェッチして、これをbookingステートに書き込みます。またFlatpickrコンポーネントをreact-flatpickrライブラリから読み込んでいます。

ERBではcontrollerから渡された@bookingがそのまま使用できたのですが、React SPAではfetch()を使い、改めてbookingオブジェクトを再構築しています。現場で使うコードの場合は、これにエラー処理を追加しますし、また現場プロジェクトのJSON APIはより複雑になりますので、フォーマット変換のparseJson()等を用意して、setBooking(parseJson(json.booking));と書くことも多いでしょう。またtype Booking...でTypeScriptの型を宣言している箇所もあります。これらはすべてJSON基礎工事でありJSXでUIを定義する前の処理ですが、かなりの分量になります

注目したいのは、JSON基礎工事で処理するフィールドの大半(user_name, email, phone)がいずれもdate pickerと関係ないことです。

Reactのメリットとして期待されるのは、一般にはより優れたUI/UXです。そしてそのために画面全体をSPAします。しかしReact化の恩恵を受ける箇所はごく一部(date pickerが使われるcheck_inフィールド)だけなのに、user_name, email, phoneフィールドも巻き込まれてしまっています。インタラクティビティが不要な箇所なのに、やらなくても良いJSON基礎工事が発生してしまったのです。

ERB

<!-- app/views/bookings/edit.html.erb -->
<%= form_with model: @booking, url: booking_path(@booking), method: :patch do |f| %>
  <div class="field"><%= f.label :user_name %>  <%= f.text_field :user_name %></div>
  <div class="field"><%= f.label :email %>      <%= f.email_field :email %></div>
  <div class="field"><%= f.label :phone %>      <%= f.telephone_field :phone %></div>
  <div class="field"><%= f.label :check_in %>   <%= f.text_field :check_in, id: "check_in" %></div>
  <%= f.submit "Update Booking" %>
<% end %>

<script>
  document.addEventListener("DOMContentLoaded", () => {
    // インタラクティブ性を`#check_in`のところだけに加える
    flatpickr("#check_in", { dateFormat: "Y-m-d", minDate: "today" });
  });
</script>

React SPA

// EditBookingForm.tsx
import { useEffect, useState } from "react";
import Flatpickr from "react-flatpickr";

type Booking = {
  // インタラクティブ性が欲しいのは `check_in`だけだが
  // id, user_name, email, phoneもobjectに持たせている
  id: number;
  user_name: string;
  email: string;
  phone: string;
  check_in: string;
};

export default function EditBookingForm({ id }: { id: string }) {
  const [booking, setBooking] = useState<Booking | null>(null);

  useEffect(() => {
    (async () => {
      const res = await fetch(`/api/bookings/${id}`);
      const json = await res.json();
      setBooking(json.booking);
    })();
  }, [id]);

  if (!booking) return <p>Loading…</p>;

  return (
    <form>
      <input
        // インタラクティビティが不要な箇所。ERBで書いても良いところ
        value={booking.user_name}
        onChange={(e) => setBooking({ ...booking, user_name: e.target.value })}
      />
      <input
        type="email"
        // インタラクティビティが不要な箇所。ERBで書いても良いところ
        value={booking.email}
        onChange={(e) => setBooking({ ...booking, email: e.target.value })}
      />
      <input
        type="tel"
        // インタラクティビティが不要な箇所。ERBで書いても良いところ
        value={booking.phone}
        onChange={(e) => setBooking({ ...booking, phone: e.target.value })}
      />
      <Flatpickr
        value={booking.check_in}
        onChange={([date]) =>
          setBooking({ ...booking, check_in: date.toISOString().slice(0, 10) })
        }
        options={{ dateFormat: "Y-m-d", minDate: "today" }}
      />
      <button>Update Booking</button>
    </form>
  );
}

フロントエンドのモダンリプレイスプロジェクトを見て思うこと

私は以前にフロントエンドのリプレイスに、いつまでかけるんだ?という記事を書きました。その中でRails ERBをReact等に書き換えようとしたプロジェクトのほとんどが何年もかかり、それでも完了していないことを紹介しました。しかし原因については分析していません。

私はこのようなモダンリプレイスプロジェクトに頓挫した現場に複数入っていますが、上述したようなJSON基礎工事のレベルで多大な時間をかけてしまったのではないかと思っています。

一見するとモダンリプレイスはフロントエンドだけの作業だと思われがちです。単にERBをReactに変えれば良いのではないかと考えてしまいます。しかし何年かけてもリプレイスが完了していない現場では、APIコントローラを新規に作成し、複雑なJSONシリアライザを導入し(例えばjsonapi-serializer gem)、コントローラをリソース毎に分けた新規(エセ)REST設計を採用したりしています

これらはいずれもJSON基礎工事の負担を増大させるものです。

JSON基礎工事の負担を減らす方法

短期間でフロントエンドをERBからReactにリプレイスしたケース(ページ数が少ないものを除く)はなかなか見かけないので、これを成功させる方法はまだよくわかりません。しかしJSON基礎工事に着目すると、下記の戦略が有効ではないかと思われます。

SPA化をやめる

上記で見たように、ページ全体をSPA化をしてしまうと、インタラクティブではないフィールドもすべてJSON APIに載せる必要があります。一方でERB直接書き込んでおけば、JSON化は不要です。

ということでSPA化をやめるのが良さそうです。これを実現する方法は(流行りの言葉では)island化と呼ばれるものです。インタラクティブ性が要求されるところだけをReactなどで書き、それ以外はサーバでレンダリングする方法です。ベースとしてERBを使えばこれは昔ながらのReactコンポーネント埋め込みになります。またベースとしてNext.jsで採用されている最新の App Routerを使えば、これはserver componentの中にclient componentを埋め込むことに相当します。

上記の例で言えば、check_inのところだけがdate pickerが必要です。ここだけをisland化すれば良いのです。それ以外のuser_name, email, phoneはサーバサイドで(ERBで)レンダリングしておいても支障がありません

JSON基礎工事を減らすための最も有効な手段は、おそらくはSPA化を止めることです。

ERBのコントローラをそのまま利用する

Railsでは1つのコントローラから複数のレスポンスを返すrespond_toメソッドがあります。これを使うと1つのコントローラからHTMLもJSONもPDFもCSVも返すことができます。Railsでscaffoldを作った時もHTMLとJSONの双方を返すコントローラが作成されます。

これを使えばJSON API専用のルートやコントローラを作成する必要がなくなります。複雑なアプリケーションだと認可等の条件分岐が複雑になっているケースも多いので、このようにコントローラをそのまま使って、ERBテンプレートの代わりにJSONを返す方法はメリットが多くなります。API専用のコントローラを作らないことによってJSON基礎工事を減らすのです。

なお、この場合はAPIは画面ファースト・画面単位になります。1つの画面を表示するのに必要な情報は、原則としてすべて1つのエンドポイントから返すと良いでしょう。クライアント側のJSON読み込みやフォーマット変換も楽になりますので、クライアント側のJSON基礎工事が減ります。APIはリソース単位の方が良いという発想もありますし、それをRESTと呼ぶ人もいますが、このような(エセ)REST設計[2]をしない方がJSON基礎工事は少なくなるでしょう。

しかし現場でよく見かけるのはこれとは違います。ERB側のコントローラが例えばBookingsControllerだった場合、API用にAPI::V1::BookingsControllerになっていることがよくあります。API専用のコントローラは一見するとBookingsControllerをそのままコピペすれば良いのではないかと思われますが、実際にはシリアライザなどのロジックがかなり入ってきたりしますし、元々のものを改変しようという欲が生まれてしまいます。そしてそのための新規のテストも必要になってきます。加えてリプレイスしている場合は、新・旧コントローラが長い間共存することになります。画面単位のAPI設計になっている場合はこれに加えてエンドポイントを増やすことになり、コントローラ数も増えます。このようにJSON基礎工事の負担がかなり多くなってしまっていますので、ERBのコントローラをそのまま利用するという方針に徹することが有用ではないかと思います。

RESTデータ要素表

JSON APIにHTMLを載せる

ERBで作られたアプリケーションでは、view helperからHTMLを返すものがあります。Railsのform helperもこうなっているので、自然なことです。この時にビジネスロジックをview helperに混ぜてしまって、バラすのが面倒くさい時があります(作業として大変ではないので、面倒臭いだけです)。

この場合はJSON APIにHTMLを載せて、React側でdangerouslySetInnerHTMLを使って表示する方法もあります。

view helperのビジネスロジックを解きほぐし、JSON API化する手間がなくなりまう。View helperの返り値をそのままJSON APIのフィールドに入れれば良いので、作業はとても簡単です。フロントエンドの自由度は損なわれますが、迅速にフロントエンドをリプレイスしたいのならばとても有効な方法です。

これもJSON API基礎工事を減らすタイプの手法になります。

最後に

上述のように、モダンフロントエンドが面倒臭いのはJSON APIのせいでないかと思います。

React/Vue等が面倒だと感じる時は、ぜひJSON APIの作り方を見直したり、そもそもJSON APIが必要かどうかを考え直したり、さらにはReact/Vue自体が必要かどうか(Hotwireで行けるのではないか)を思い返すのが良さそうです。

JSON基礎工事は大変神経を使い、面倒でかつ複雑です。しかもUI/UXに反映されない裏方の仕事です。これが楽しいという人はいないと思いますし、無くせるなら無くしたいものです。

React/Vueは楽しいです。つまらないのはJSONです。

脚注
  1. RailsガイドにはERBテスト手法のセクションがあります。またRSpecもview specsの項目があります。このようにRailsはviewのテストが厚くサポートされていますが、実際の現場では滅多にテストを書きません。これには明確な理由があります(理由までハッキリ理解しているRails開発者は少ないとは思いますが...)。
    Rubyは外部からオブジェクトの属性に直接アクセスすることは不可能で、必ずgetterを通してアクセスします。例えば@booking.email@bookingに定義された#email()メソッドを呼び出します。そのためミススペルとかリファクタリング等によって存在しないものにアクセスしようとすると、no methodエラーが発生してすぐに気づきます。そしてRSpecのrequest spec、minitestのfunctional testを実行するとERBはレンダリングされますので、CIでエラーに気づきます。
    一方でJavaScript (React/Vue)の場合はオブジェクトの属性(フィールド)に直接アクセスします。ミススペルをした場合はundefinedが返ってきますが、React/Vueはこれを空白文字列("")に変換しつつ、黙ってそのままレンダリングします。そのため、フィールド等のミスマッチのバグが発見されずに見過ごされやすくなります。TypeScriptを使ってもこのバグは発見できません(Zodを使えばRubyと同じようにランタイムでエラーになります)。
    上記の理由により、React/Vue用のJSON APIを作る時はミススペル等に神経を使う必要がありますが、一方でRails ERBの場合はほとんど気を使わなくても大丈夫です。ただしこれはあくまでもrequest specを書いている場合です。(中身はほとんど空っぽでも)request specを書いてさえいれば大丈夫です(ERBのテストでは、上記の例のexpect(booking_json.keys).to include...相当のものは一般に不要です)。
    Rubyは動的型付け言語ですが、実行時には強い型付けをします。それに対してJavaScript/React/Vueは実行時に弱い型付けになります。そのため皮肉ですが、テストの中で軽く実行さえしておけば、Rubyの方がTypeScriptよりも安全になることがあります。 ↩︎

  2. 「(エセ)REST設計」という言葉を使っているのは、Roy FieldingがRESTを発明した時、JSON APIというものはそもそも存在せず、むしろHTMLが想定されていたからです。「REST型のAPIはテーブル構造に忠実にデータを返すべき」という発想を時々聞きますし、それをベースに設計されたAPIを見かけますが、これはRESTの発想ではありません。RESTのベースはHTMLなので、HTMLをレンダリングするのに必要な情報がすべて1つのREST APIエンドポイントに載っているのはむしろ自然なことです。
    バックエンドから見た時、テーブル(リソース)構造に忠実にデータを返すのは一見すると楽に思えます。しかし本番で長期に運用していくと、N+1問題はもちろんのこと、サーバ負荷の増大、複雑な権限によるフィールドの出し分け等に対応していく必要があります。リソース構造を守る設計よりも、各画面の要望に柔軟に対応できる設計の方が長期的にはメリットが多いと思います。 ↩︎

Discussion