Rails7でHotwireのTurboを使う

こんにちは、エンジニアの太田です。

この記事は Enigmo Advent Calendar 2023 の16日目の記事です。

はじめに

TurboはRails7からデフォルトで搭載されており、VueやReactなどjavascriptの記述が必要だったDOMの更新をjavascriptを(あまり)書かずに実現させてくれます。 フロントエンドにあまり触れない方にとっては、SPA風のwebアプリへのとっつきやすさが出たと思います。
本記事では、私が初めてTurboに触れて使い方を覚える際に作成したサンプルの一部と使った感想を備忘録的にまとめたものになります。
各公式ドキュメントでも使い方を確認できます。
Rails で JavaScript を利用する - Railsガイド *1
Turbo Handbook *2

サンプルコード

以下は私が主に使用した形です。 ransack*3とkaminari*4を使ったリストの更新と追加・編集・削除をしてみます。

controllers

class CountriesController < ApplicationController
  def index
    @q = Country.ransack(params[:q])
    @q.sorts = 'name asc'
    @countries = @q.result.page(params[:page])

    # Request HeadersにTurbo-Frameが設定されているとpartialがreturnされて、
    # 対象の要素がpartialに置換される
    if turbo_frame_request?
      render partial: 'list', locals: { countries: @countries }
    end
  end

  def show
    @country = Country.find(params[:id])
  end

  def edit
    @country = Country.find(params[:id])
  end

  def create
    @country = Country.create!(country_params)
  end

  def update
    @country = Country.find(params[:id])
    @country.update!(country_params)
  end

  def destroy
    @country = Country.find(params[:id])
    @country.destroy!
  end

  private
    def country_params
      params.require(:country).permit(:code, :name)
    end
end

models

class Country < ApplicationRecord
  # ransackで検索項目にしたカラムを記載したのみ
  def self.ransackable_attributes(_auth_object = nil)
    %w[name]
  end
end

views

index.html.erb

# このフォーム内からのリクエストはヘッダーにTurbo-Frameを設定する
# レスポンスのpartialによってid属性がlistのturbo-frame要素が置換される
<%= search_form_for @q, html: { data: { turbo_frame: 'list' } } do |f| %>
  <div>
    <%= f.label :name_cont, 'name' %>
    <%= f.text_field :name_cont %>
  </div>
  <div>
    <%= f.submit '検索' %>
    <%= link_to 'リセット', countries_path, data: { turbo_frame: "_top" } %>
  </div>
<% end %>

<%= render 'list', countries: @countries %>

<%= render 'form' %>


_list.html.erb

# id属性がlistのturbo-frame要素
<%= turbo_frame_tag :list, autoscroll: true, data: { autoscroll_block: 'start' } do %>
  <ul id="countries">
    <li>
      <div>国コード</div>
      <div>国名</div>
      <div></div>
      <div></div>
    </li>
    <%= render countries %>
  </ul>
  <div>

    # turbo_frame_tag内は自動的に直近の親を対象としたTurbo-Frameのリクエストになる
    # なのでkaminariにTurbo用の設定は不要
    <%= paginate countries %>

  </div>
<% end %>


_country.html.erb

# id属性がcountry_{country.id}のturbo-frame要素
<%= turbo_frame_tag country do %>
  <div><%= country.code %></div>
  <div><%= country.name %></div>

  # 直近の親 (id属性がcountry_{country.id}のturbo-frame要素)が対象のTurbo-Frameのリクエストになる
  # edit.html.erbに置換される
  <div><%= button_to '編集', edit_country_path(country), method: :get %></div>

  # GET以外のメソッドではTurbo-Streamのリクエストになる
  # destroy.turbo_stream.erbの処理を実行
  <div><%= button_to '削除', country_path(country), method: :delete %></div>
<% end %>


edit.html.erb

# id属性がcountry_{@country.id}のturbo-frame要素
<%= turbo_frame_tag @country do %>
  <div>
    <%= form_with model: @country do |form| %>
      <%= form.text_field :code %>
      <%= form.text_field :name %>

      # 直近の親 (id属性がcountry_{@country.id}のturbo-frame要素)が対象のTurbo-Streamのリクエストになる
      # destroy.turbo_stream.erbの処理を実行
      <div><%= button_to '保存', action: :update %></div>
    <% end %>

    # GETメソッドなのでTurbo-Frameのリクエストになる
    # show.html.erbに置換される
    <div><%= button_to '中止', country_path(@country), method: :get %></div>
  </div>
<% end %>


show.html.erb

<%= render 'country', country: @country %>


update.turbo_stream.erb

# id属性がcountry_{@country.id}のturbo-frame要素をupdate後に置き換える
<%= turbo_stream.replace @country %>


create.turbo_stream.erb

# id属性がcountriesの要素の末尾に_country.html.erbを追加
<%= turbo_stream.append 'countries', @country %>

# 登録フォームを入力をリセット
<%= turbo_stream.replace 'register' do %>
  <%= render 'form' %>
<% end %>


_form.html.erb

# 登録フォームのid属性registerを設定しておく
<%= form_with model: Country.new, id: 'register' do |form| %>
  <div><%= form.label :code %><%= form.text_field :code %></div>
  <div><%= form.label :name %><%= form.text_field :name %></div>
  <div><%= form.submit %></div>
<% end %>


destroy.turbo_stream.erb

# id属性がcountry_{@country.id}のturbo-frame要素を削除
<%= turbo_stream.remove @country %>

turbo-frame

turbo-frameは画面内のturbo-frameタグを一つだけを対象として置換することができます。id属性必須です。
リストの更新が主な使用ケースでした。

turbo-stream

turbo-streamは画面内の複数要素をid属性で指定して対象とすることができ、それぞれに対して下記の7つの処理*5を実行できます。

  • 先頭追加
  • 末尾追加
  • 直前追加
  • 直後追加
  • 置換(対象要素含む)
  • 更新(対象要素含まず中身だけ)
  • 削除

この時、対象とする要素はturbo-frameタグではなくて普通のdivタグなども指定可能です。注意点はGET以外のリクエストにする必要があることです。
登録時にリストへ要素を追加すると同時にフォームをリセットするなどが主な使用ケースでした。フラッシュを表示したりで複数箇所の更新が必要になりがちなDB操作が絡むリクエストでの使用機会が多いと思います。

動きのイメージ

リストを更新
検索をリセットして初期化
編集フォームに置換
編集を保存して反映
編集をやめる
データを削除
データを登録

細かい話

Hotwireとは、Turboとは

下記の公式の説明やimport文からHotwireという開発アプローチがあって、それを実現させるためのパッケージにTurboというパッケージがあるという感じのようです。

Hotwire is an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire. *6

import * as Turbo from "@hotwired/turbo"

HotwireにはTurbo以外にもStimulusとStradaがあり、この三つの要素から成ります。 Rails7以降ではデフォルトでTurboとStimulusが使えるようになっています。

# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"

# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"

ReactやVueとの違い

前述の公式説明には「without using much JavaScript by sending HTML instead of JSON over the wire.」とありますが、これがHotwireがReactやVueと異なるポイントです。
RailsでReactやVueを使う場合は、サーバサイドのrailsがcontrollerでデータをreturnし、そのデータを取得するようにクライアントサイドのReactやVueの実装をするかと思います。
一方でHotwireでは、上に挙げたサンプルコードのようにリクエストに対してcontrollerがHTML(render partial)をreturnするだけになります。
確かに、JavaScriptを使わずにJSONではなくてHTMLを送信するようになっています。

Turboを使った感想

ransackやkaminariといったviewsを構成するファイルに対するgemをそのまま使えて、わざわざフロントエンド用にnpm installとかしなくて済むのがとてもよかったです。
DOM更新はほとんどJavaScriptを記述しなくてもよかったのもあり、作業工数もそこそこ少なく済むのではないかと思います。
使いづらいところとしては、turbo-frameタグで全体を囲わなければならないのでスタイルの当て方が少し面倒になる場面がありました。cssフレームワークを使う場合は結構気を使う必要があるかもしれません。
また、各アクションでhtmlを返す必要があるので、必然的にviewsディレクトリのファイルが多くります。jsの記述が必要無くなった分という感じです
。 一般公開する大きなサービスではちょっと頼りなさそうな感じはしましたが、他の業務もしながら開発する社内ツールくらいの規模であれば十分なものだと思いました。


最後までご覧いただきありがとうございました。

明日の記事の担当は、コーポレートエンジニア(コーポレートIT[CO-IT]チーム) の横川さんです。お楽しみに!


株式会社エニグモ すべての求人一覧

hrmos.co