Ruby 3.0.6＋Rails 7.1.2で「Learn Hotwire and Turbo with a free Rails 7 tutorial」をやってみた

この記事は、[Ruby Advent Calendar 2023 シリーズ1](https://qiita.com/advent-calendar/2023/ruby) の20日目です

昨日は、 @HAZI さんで [「現在開発中の『SpeedLimiter』Gemの紹介」](https://zenn.dev/seibii/articles/20231219_speed_limiter) でした

---

[piacere](https://twitter.com/piacere_ex) です、ご覧いただいてありがとございます :bow:

普段は、[Elixir／Phoenix／LiveView](https://qiita.com/advent-calendar/2023/elixir)メインで開発してますが、Ruby／Railsも年2本くらい、既存システムのバージョンアップ案件やリプレイス案件をこなしている（実はRails 1系からの付き合いで結構古い）ので、何かと触ってはいるものの、そこまで新しいフィーチャは使う機会が無いです

そこで、前々から何となくは知ってたものの、ちゃんと触ったことが無かった「HotWire」について調べてみたいと思います

Elixirにてサーバサイド主体のリアルタイムWeb／SPAを叶える「LiveView」と似た性質を持っていると言われるHotWireなので、とても楽しみです

なお、LiveViewについては以前コラム書いていますので、良かったらご覧ください :information_desk_person_tone1:

https://qiita.com/piacerex/items/3f8ee18c9443d63955bf

<font color="red">【202312/22追記】</font>
Python／DjangoのHotWireについてもコラム化しました

https://qiita.com/piacerex/items/bf2a32581579ab69f45b

<font color="red">【202312/23追記】</font>
PHP／Laravelの同種であるLiveWireについてもコラム化しました

https://qiita.com/piacerex/items/52a610957a143e4687dd

あと、このコラムが、面白かったり、役に立ったら、![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) をお願いします :bow:

# 事前準備：Rails 7以降が入っていれば特に無し

HotWireは、Rails 7以降だと標準で入っているので、特に何もせずに使えるようです（本コラムはRuby 3.0.6＋Rails 7.1.2での実施結果が書かれています）

下記チュートリアルを見ながら進めます

**Learn Hotwire and Turbo with a free Rails 7 tutorial**

https://www.hotrails.dev/turbo-rails/turbo-rails-tutorial-introduction

# Chapter 0「Turbo Rails tutorial introduction」

Rails PJを作成し、起動します

```bash
rails new quote-editor --css=sass --javascript=esbuild --database=postgresql
cd quote-editor
bin/setup
bin/rails server
```

ブラウザで `http://localhost:3000` にアクセスすると、親の顔より良く見たRailsデフォルトページが表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/3c3c3883-b1d2-0d1d-fc74-3e68fa0bea76.png)

# Chapter 1「A simple CRUD controller with Rails」

シンプルなCRUDを作っていきます

https://www.hotrails.dev/turbo-rails/crud-controller-ruby-on-rails

## Creating our Quote model

モデルをScaffoldします（なお、この手前のテストはスッ飛ばしています）

```bash
bin/rails generate model Quote name:string
bin/rails db:migrate
```

## Adding our routes and controller actions

コントローラをScaffoldします

```bash
bin/rails generate controller Quotes
```

routerに追加します

```diff_ruby:config/routes.rb
Rails.application.routes.draw do
…
  # Defines the root path route ("/")
  # root "posts#index"
+ Rails.application.routes.draw do
+   resources :quotes
+ end
end
```

Controllerの中身を実装します

```diff_ruby:app/controllers/quotes_controller.rb
class QuotesController < ApplicationController
+  before_action :set_quote, only: [:show, :edit, :update, :destroy]
+
+  def index
+    @quotes = Quote.all
+  end
+
+  def show
+  end
+
+  def new
+    @quote = Quote.new
+  end
+
+  def create
+    @quote = Quote.new(quote_params)
+
+    if @quote.save
+      redirect_to quotes_path, notice: "Quote was successfully created."
+    else
+      render :new
+    end
+  end
+
+  def edit
+  end
+
+  def update
+    if @quote.update(quote_params)
+      redirect_to quotes_path, notice: "Quote was successfully updated."
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    @quote.destroy
+    redirect_to quotes_path, notice: "Quote was successfully destroyed."
+  end
+
+  private
+
+  def set_quote
+    @quote = Quote.find(params[:id])
+  end
+
+  def quote_params
+    params.require(:quote).permit(:name)
+  end
end
```

## Adding our quote views

View群を追加します

```diff_erb:app/views/quotes/index.html.erb
<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary" %>
  </div>

  <%= render @quotes %>
</main>
```

```diff_erb:app/views/quotes/_quote.html.erb
<div class="quote">
  <%= link_to quote.name, quote_path(quote) %>
  <div class="quote__actions">
    <%= button_to "Delete",
                  quote_path(quote),
                  method: :delete,
                  class: "btn btn--light" %>
    <%= link_to "Edit",
                edit_quote_path(quote),
                class: "btn btn--light" %>
  </div>
</div>
```

```diff_erb:app/views/quotes/new.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

  <%= render "form", quote: @quote %>
</main>
```

```diff_erb:app/views/quotes/edit.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

  <%= render "form", quote: @quote %>
</main>
```

```diff_erb:app/views/quotes/_form.html.erb
<%= simple_form_for quote, html: { class: "quote form" } do |f| %>
  <% if quote.errors.any? %>
    <div class="error-message">
      <%= quote.errors.full_messages.to_sentence.capitalize %>
    </div>
  <% end %>

  <%= f.input :name, input_html: { autofocus: true } %>
  <%= f.submit class: "btn btn--secondary" %>
<% end %>
```

simple_formをインストールします

```diff_ruby:Gemfile
source "https://rubygems.org"
…
+gem "simple_form", "~> 5.1.0"
```

```bash
bundle install
bin/rails generate simple_form:install
```

Show用Viewも追加します

```diff_erb:app/views/quotes/show.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>
  <div class="header">
    <h1>
      <%= @quote.name %>
    </h1>
  </div>
</main>
```

Railsを起動して確認してみましょう

```bash
bin/rails server
```

テスト用seedデータの追加は割愛するとして、ひとまず、SSR版が動くように成りました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/30090524-824f-f3f1-9856-51e947200eb4.png)

サンプルデータを突っ込んでおきます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ac2bfdbe-4566-b0ac-d822-36e36fccd44f.png)

# Chapter 2「Organizing CSS files in Ruby on Rails」

ただのデザインなので、機械的に下記ファイル追加をこなしていきます

https://www.hotrails.dev/turbo-rails/css-ruby-on-rails

## Using our CSS architecture on our quote editor

```diff_scss:app/assets/stylesheets/mixins/_media.scss
@mixin media($query) {
  @if $query == tabletAndUp {
    @media (min-width: 50rem) { @content; }
  }
}
```

下記は、元コラムでやたら分割されていたので、繋げたものを下記に示します

```diff_scss:app/assets/stylesheets/config/_variables.scss
:root {
  // Simple fonts
  --font-family-sans: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;

  // Classical line heights
  --line-height-headers: 1.1;
  --line-height-body:    1.5;

  // Classical and robust font sizes system
  --font-size-xs: 0.75rem;   // 12px
  --font-size-s: 0.875rem;   // 14px
  --font-size-m: 1rem;       // 16px
  --font-size-l: 1.125rem;   // 18px
  --font-size-xl: 1.25rem;   // 20px
  --font-size-xxl: 1.5rem;   // 24px
  --font-size-xxxl: 2rem;    // 32px
  --font-size-xxxxl: 2.5rem; // 40px

  // Three different text colors
  --color-text-header: hsl(0, 1%, 16%);
  --color-text-body:   hsl(0, 5%, 25%);
  --color-text-muted:  hsl(0, 1%, 44%);

  // Classical and robust spacing system
  --space-xxxs: 0.25rem; // 4px
  --space-xxs: 0.375rem; // 6px
  --space-xs: 0.5rem;    // 8px
  --space-s: 0.75rem;    // 12px
  --space-m: 1rem;       // 16px
  --space-l: 1.5rem;     // 24px
  --space-xl: 2rem;      // 32px
  --space-xxl: 2.5rem;   // 40px
  --space-xxxl: 3rem;    // 48px
  --space-xxxxl: 4rem;   // 64px

  // Application colors
  --color-primary:          hsl(350, 67%, 50%);
  --color-primary-rotate:   hsl(10, 73%, 54%);
  --color-primary-bg:       hsl(0, 85%, 96%);
  --color-secondary:        hsl(101, 45%, 56%);
  --color-secondary-rotate: hsl(120, 45%, 56%);
  --color-tertiary:         hsl(49, 89%, 64%);
  --color-glint:            hsl(210, 100%, 82%);

  // Neutral colors
  --color-white:      hsl(0, 0%, 100%);
  --color-background: hsl(30, 50%, 98%);
  --color-light:      hsl(0, 6%, 93%);
  --color-dark:       var(--color-text-header);

  // Border radius
  --border-radius: 0.375rem;

  // Border
  --border: solid 2px var(--color-light);

  // Shadows
  --shadow-large:  2px 4px 10px hsl(0 0% 0% / 0.1);
  --shadow-small:  1px 3px 6px hsl(0 0% 0% / 0.1);
}
```

```diff_scss:app/assets/stylesheets/config/_reset.scss
*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
  padding: 0;
}

html {
  overflow-y: scroll;
  height: 100%;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100%;

  background-color: var(--color-background);
  color: var(--color-text-body);
  line-height: var(--line-height-body);
  font-family: var(--font-family-sans);
}

img,
picture,
svg {
  display: block;
  max-width: 100%;
}

input,
button,
textarea,
select {
  font: inherit;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  color: var(--color-text-header);
  line-height: var(--line-height-headers);
}

h1 {
  font-size: var(--font-size-xxxl);
}

h2 {
  font-size: var(--font-size-xxl);
}

h3 {
  font-size: var(--font-size-xl);
}

h4 {
  font-size: var(--font-size-l);
}

a {
  color: var(--color-primary);
  text-decoration: none;
  transition: color 200ms;

  &:hover,
  &:focus,
  &:active {
    color: var(--color-primary-rotate);
  }
}
```

下記も、繋げたものを下記に示します

```diff_scss:app/assets/stylesheets/components/_btn.scss
.btn {
  display: inline-block;
  padding: var(--space-xxs) var(--space-m);
  border-radius: var(--border-radius);
  background-origin: border-box; // Invisible borders with linear gradients
  background-color: transparent;
  border: solid 2px transparent;
  font-weight: bold;
  text-decoration: none;
  cursor: pointer;
  outline: none;
  transition: filter 400ms, color 200ms;

  &:hover,
  &:focus,
  &:focus-within,
  &:active {
    transition: filter 250ms, color 200ms;
  }

  // Modifiers will go there

  &--primary {
    color: var(--color-white);
    background-image: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
      filter: saturate(1.4) brightness(115%);
    }
  }

  &--secondary {
    color: var(--color-white);
    background-image: linear-gradient(to right, var(--color-secondary), var(--color-secondary-rotate));

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
      filter: saturate(1.2) brightness(110%);
    }
  }

  &--light {
    color: var(--color-dark);
    background-color: var(--color-light);

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-dark);
      filter: brightness(92%);
    }
  }

  &--dark {
    color: var(--color-white);
    border-color: var(--color-dark);
    background-color: var(--color-dark);

    &:hover,
    &:focus,
    &:focus-within,
    &:active {
      color: var(--color-white);
    }
  }
}
```

```diff_scss:app/assets/stylesheets/components/_quote.scss
.quote {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-s);

  background-color: var(--color-white);
  border-radius: var(--border-radius);
  box-shadow: var(--shadow-small);
  margin-bottom: var(--space-m);
  padding: var(--space-xs);

  @include media(tabletAndUp) {
    padding: var(--space-xs) var(--space-m);
  }

  &__actions {
    display: flex;
    flex: 0 0 auto;
    align-self: flex-start;
    gap: var(--space-xs);
  }
}
```

```diff_scss:app/assets/stylesheets/components/_form.scss
.form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);

  &__group {
    flex: 1;
  }

  &__input {
    display: block;
    width: 100%;
    max-width: 100%;
    padding: var(--space-xxs) var(--space-xs);
    border: var(--border);
    border-radius: var(--border-radius);
    outline: none;
    transition: box-shadow 250ms;

    &:focus {
      box-shadow: 0 0 0 2px var(--color-glint);
    }

    &--invalid {
      border-color: var(--color-primary);
    }
  }
}
```

```diff_scss:app/assets/stylesheets/components/_visually_hidden.scss
// Shamelessly stolen from Bootstrap

.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important;
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;
}
```

```diff_scss:app/assets/stylesheets/components/_error_message.scss
.error-message {
  width: 100%;
  color: var(--color-primary);
  background-color: var(--color-primary-bg);
  padding: var(--space-xs);
  border-radius: var(--border-radius);
}
```

```diff_scss:app/assets/stylesheets/layouts/_container.scss
.container {
  width: 100%;
  padding-right: var(--space-xs);
  padding-left: var(--space-xs);
  margin-left: auto;
  margin-right: auto;

  @include media(tabletAndUp) {
    padding-right: var(--space-m);
    padding-left: var(--space-m);
    max-width: 60rem;
  }
}
```

```diff_scss:app/assets/stylesheets/layouts/_header.scss
.header {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-s);
  justify-content: space-between;
  margin-top: var(--space-m);
  margin-bottom: var(--space-l);

  @include media(tabletAndUp) {
    margin-bottom: var(--space-xl);
  }
}
```

```diff_scss:app/assets/stylesheets/application.sass.scss
// Mixins
@import "mixins/media";

// Configuration
@import "config/variables";
@import "config/reset";

// Components
@import "components/btn";
@import "components/error_message";
@import "components/form";
@import "components/visually_hidden";
@import "components/quote";

// Layouts
@import "layouts/container";
@import "layouts/header";
```

デザインが入りました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ac913aab-e84c-4732-9026-7cf2a121f634.png)

# Chapter 3「Turbo Drive」

さて、やっと本題です

https://www.hotrails.dev/turbo-rails/turbo-drive

全てのリンククリックとフォーム送信をAJAXリクエストに変換（≒リクエストを傍受して、上書きする）する「Turbo Drive」により、`<body>` タグ内の一部変更のみとなり、`<head>` およびそこでロードされているCSS／JSのロードは最初にサイトアクセスしたときだけとなり、従来のSSRよりも高速化されるという仕組みです

その後、コラムでは、Turbo Driveを無効化する解説あります … ん？…ということは、Rails標準の状態で、Turbo Driveは有効化されているということ！？

コラムに無効化の方法もあるので、試しに無効化して確かめてみましょう

```diff_javascript:app/javascript/application.js
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
+
+import { Turbo } from "@hotwired/turbo-rails"
+Turbo.session.drive = false
```

ページ移動するたびにリロードが走っています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9a4865e2-e59a-0d0e-e22c-957b15573f12.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9b86728d-b26a-d2f4-3a60-8ece713c709f.png)

それでは、無効化を解除して、同じことを試します

```diff_javascript:app/javascript/application.js
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
-
-import { Turbo } from "@hotwired/turbo-rails"
-Turbo.session.drive = false
```

ページ移動のたびにリロードが走らず、AJAXによる部分ロードが行われていることが分かります（ちなみにURLは変わっています）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ed04cc5a-129b-7fe8-65d8-c2bc83f0d2ea.png)

つまり、Rails 7デフォルトにした時点で、Turbo Driveによる部分ロードが有効になっていたということですね :smile: 

次に、CSSをエラー状態にすると、どうなるでしょう？

```diff_scss:app/assets/stylesheets/application.sass.scss
// Mixins
@import "mixins/media";

// Configuration
@import "config/variables";
@import "config/reset";

// Components
@import "components/btn";
+@import "components/btn";
@import "components/error_message";
@import "components/form";
@import "components/visually_hidden";
@import "components/quote";

// Layouts
@import "layouts/container";
@import "layouts/header";
```

ページ移動時にAJAXでは無く、リロードになりました … 良く出来ています（確認したら、CSSは元に戻してください）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/9da3d19e-c33f-598b-dcf4-7fa3b6df6be9.png)

さて、このようにAJAXで上書きされた影響で、ブラウザのロード状態でレスポンス待ちが分かりにくくなるというデメリットがあります

コラムの順と異なりますが、まずそのことを確認するため、3秒のウェイトを全コントローラに入れます

```diff_ruby::app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
+ before_action -> { sleep 3 }
end
```

んん？…ちゃんとプログレスバー出てるけどなぁ？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/6d94f870-4d40-8c94-fb84-662cf6f28781.png)

まぁ、一応、プログレスバーのスタイル設定をしてみます

```diff_scss:app/assets/stylesheets/components/_turbo_progress_bar.scss
.turbo-progress-bar {
  background: linear-gradient(to right, var(--color-primary), var(--color-primary-rotate));
}
```

```diff_scss: app/assets/stylesheets/application.sass.scss
…
@import "components/turbo_progress_bar";
```

うん、プログレスバーが赤色に変わっただけですねw
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/cb9fad71-7082-ec9c-215a-cf41fcf27df9.png)

もしかしたら、ココはブラウザ依存なのかも知れません（私はBraveで確認しました）

# Chapter 4「Turbo Frames and Turbo Stream templates」

テストは飛ばし、「Turbo Frames」についてやっていきます

https://www.hotrails.dev/turbo-rails/turbo-frames-and-turbo-streams

## What are Turbo Frames?

`turbo_frame_tag` ブロックでTurbo Framesが作成できます

```diff_erb:app/views/quotes/index.html.erb
<main class="container">
+ <%= turbo_frame_tag "first_turbo_frame" do %>
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary" %>
  </div>
+ <% end %>

  <%= render @quotes %>
</main>
```

ブラウザでソースを見ると、`<turbo-frame id="first_turbo_frame">` という感じで、専用タグで出力されています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/d40784c2-31c6-c54e-2de4-dd5acd8cdcc2.png)

この段階だと、特に何も変わりは無いです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/e932fb8a-c749-a95c-11cf-0fcb5c279748.png)

## Turbo Frames cheat sheet

ここで、Newの方にも同じ名前の `turbo_frame_tag` ブロックを置いてみます

```diff_erb:app/views/quotes/new.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

+ <%= turbo_frame_tag "first_turbo_frame" do %>
    <%= render "form", quote: @quote %>
+ <% end %>
</main>
```

すると、同じ名前の `turbo_frame_tag` ブロックのところだけがAJAXで差し替わります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/c7cd3783-42f3-12af-2f6a-8debe61a7379.png)

もう少し見た目を意識すると、`h1` のところは、フレームから外してみましょう

```diff_erb:app/views/quotes/index.html.erb
<main class="container">
- <%= turbo_frame_tag "first_turbo_frame" do %>
  <div class="header">
    <h1>Quotes</h1>
+ <%= turbo_frame_tag "first_turbo_frame" do %>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary" %>
+   <% end %>
  </div>
- <% end %>

  <%= render @quotes %>
</main>
```

これで、タイトルとリストは維持したまま、Newの箇所だけが差し替わる感じにできました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/6ea4eb29-48e2-dc56-2ad9-776e0ef3b6dc.png)

マッチしない名前に変えるとどうなるでしょう？

```diff_erb:app/views/quotes/new.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

- <%= turbo_frame_tag "first_turbo_frame" do %>
+ <%= turbo_frame_tag "not_matching" do %>
    <%= render "form", quote: @quote %>
+ <% end %>
</main>
```

エラーになります（元に戻してください）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/8dfe89e0-8bfa-0b94-8b0b-8c9116158247.png)

今度は、Indexのリスト部のフレームをNewのフレームと同一にしてみましょう

```diff_erb:app/views/quotes/index.html.erb
<main class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= turbo_frame_tag "first_turbo_frame" do %>
      <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary" %>
    <% end %>
  </div>

+ <%= turbo_frame_tag "second_frame" do %>
    <%= render @quotes %>
+ <% end %>
</main>
```

```diff_erb:app/views/quotes/new.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

- <%= turbo_frame_tag "not_matching" do %>
+ <%= turbo_frame_tag "second_frame" do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>
```

Newを押すと、リスト部分が新規入力になります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/c36f552a-f23b-d30f-b1e1-c5de47bae1c8.png)

そして登録すると、新規入力部分が、リストになり、新規も追加されていることが確認できるようになります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/5f8cc56c-d914-13a6-b066-379c53b1860d.png)

ページ全体を置き換え対象にもできます

```
<main class="container">
+ <%= turbo_frame_tag "first_turbo_frame" do %>
  <div class="header">
    <h1>Quotes</h1>
      <%= link_to "New quote",
                new_quote_path,
+               data: { turbo_frame: "_top" },
                class: "btn btn--primary" %>
  </div>
+ <% end %>

- <%= turbo_frame_tag "second_frame" do %>
    <%= render @quotes %>
- <% end %>
</main>
```

この場合、フレーム化は最初のページだけで充分です

```diff_erb:app/views/quotes/new.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quotes"), quotes_path %>

  <div class="header">
    <h1>New quote</h1>
  </div>

- <%= turbo_frame_tag "second_frame" do %>
    <%= render "form", quote: @quote %>
- <% end %>
</main>
```

全体が置き換わるのが確認できます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/1e3f86f4-6ba8-38d7-059b-4351321a5684.png)

## Editing quotes with Turbo Frames

編集のような特定IDのフレーム化は、各行の中身である_quote全体を `quote.id` でナンバリングされた名前のフレームで囲みます

```diff_erb:app/views/quotes/_quote.html.erb
+<%= turbo_frame_tag "quote_#{quote.id}" do %>
  <div class="quote">
    <%= link_to quote.name, quote_path(quote) %>
    <div class="quote__actions">
      <%= button_to "Delete",
                    quote_path(quote),
                    method: :delete,
                    class: "btn btn--light" %>
      <%= link_to "Edit",
                  edit_quote_path(quote),
                  class: "btn btn--light" %>
    </div>
  </div>
+<% end %>
```

編集フォームも `@quote.id` でナンバリングされた名前のフレームで囲みます

```diff_erb:app/views/quotes/edit.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

+ <%= turbo_frame_tag "quote_#{@quote.id}" do %>
    <%= render "form", quote: @quote %>
+ <% end %>
</main>
```

ナンバリングされたフレームが付いています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/760b3763-708b-0312-aea0-561eb523b81a.png)

編集ボタンを押すと、該当行のみが編集に切り替わります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/fc370b4a-7d0d-bbe7-ab15-293a149fcc2b.png)

## Turbo Frames and the dom_id helper

`dom_id` ヘルパーの恩恵で、ID指定では無く、データそのものを指定したフレームでも処理できます

```diff_erb:app/views/quotes/_quote.html.erb
-<%= turbo_frame_tag "quote_#{quote.id}" do %>
+<%= turbo_frame_tag quote do %>
<div class="quote">
  <%= link_to quote.name, quote_path(quote) %>
  <div class="quote__actions">
    <%= button_to "Delete",
                  quote_path(quote),
                  method: :delete,
                  class: "btn btn--light" %>
    <%= link_to "Edit",
                edit_quote_path(quote),
                class: "btn btn--light" %>
  </div>
</div>
<% end %>
```

```diff_erb:app/views/quotes/edit.html.erb
<main class="container">
  <%= link_to sanitize("&larr; Back to quote"), quote_path(@quote) %>

  <div class="header">
    <h1>Edit quote</h1>
  </div>

- <%= turbo_frame_tag "quote_#{@quote.id}" do %>
+ <%= turbo_frame_tag @quote do %>
    <%= render "form", quote: @quote %>
  <% end %>
</main>
```

動作は上記と同様です

## Showing and deleting quotes

ただし、現在の構造には問題があって、リンクを押す、もしくは削除を行うと、どちらも下図のエラーになります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/a4be2efa-1af7-7ffc-42a2-61466a9599ab.png)

これを解決するには、削除ボタンに `data` を指定し、削除時も全体を置き換えるようにします

```diff_erb:app/views/quotes/_quote.html.erb
<%= turbo_frame_tag quote do %>
<div class="quote">
- <%= link_to quote.name, quote_path(quote) %>
+ <%= link_to quote.name, quote_path(quote), data: { turbo_frame: "_top" } %> %>
  <div class="quote__actions">
    <%= button_to "Delete",
                  quote_path(quote),
                  method: :delete,
+                 form: { data: { turbo_frame: "_top" } },
                  class: "btn btn--light" %>
    <%= link_to "Edit",
                edit_quote_path(quote),
                class: "btn btn--light" %>
  </div>
</div>
<% end %>
```

ただし、この対応だと、編集中の状態から別行の削除を行うと、編集状態がリセットされてしまうため、不完全な対応です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/b8144286-6eb7-449b-4512-24c4a6e7ea73.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/01f71997-3a94-a716-8163-ad50e5b19f29.png)

これを避けるためには、先ほど削除ボタンに対して行った改修を無かったことにします

```diff_erb:app/views/quotes/_quote.html.erb
<%= turbo_frame_tag quote do %>
<div class="quote">
  <%= link_to quote.name, quote_path(quote) %>
  <%= link_to quote.name, quote_path(quote), data: { turbo_frame: "_top" } %> %>
  <div class="quote__actions">
    <%= button_to "Delete",
                  quote_path(quote),
                  method: :delete,
-                 form: { data: { turbo_frame: "_top" } },
                  class: "btn btn--light" %>
    <%= link_to "Edit",
                edit_quote_path(quote),
                class: "btn btn--light" %>
  </div>
</div>
<% end %>
```

削除行にエラーは出ますが、編集状態は維持されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/251039b7-d87d-9f8b-1fd1-23cae510dc22.png)

## The Turbo Stream format

削除時のログを見ると、`QuotesController#destroy` がTurbo Streamとして実行されているようです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/f3a7bf23-6766-32db-7d7e-283fb00ba296.png)

Turbo Streamに対応した削除処理にしてみましょう

```diff_ruby:app/controllers/quotes_controller.rb
class QuotesController < ApplicationController
…
  def destroy
    @quote.destroy
-   redirect_to quotes_path, notice: "Quote was successfully destroyed."
+   respond_to do |format|
+     format.html { redirect_to quotes_path, notice: "Quote was successfully destroyed." }
+     format.turbo_stream
+   end
  end
…
```

削除実施用のViewも定義します

```diff_ruby:app/views/quotes/destroy.turbo_stream.erb
<%= turbo_stream.remove "quote_#{@quote.id}" %>
```

削除してもエラーにならなくなりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/4bca18a3-f96f-dd3c-9c2e-42307915e82c.png)

Turbo Streamは、remove以外にも、下記に対応しているようです

```diff_ruby
# Remove a Turbo Frame
turbo_stream.remove

# Insert a Turbo Frame at the beginning/end of a list
turbo_stream.append
turbo_stream.prepend

# Insert a Turbo Frame before/after another Turbo Frame
turbo_stream.before
turbo_stream.after

# Replace or update the content of a Turbo Frame
turbo_stream.update
turbo_stream.replace
```

なお、Turbo Streamもdom_id指定が可能です

```diff_ruby:app/views/quotes/destroy.turbo_stream.erb
<%= turbo_stream.remove @quote %>
```

# 以降は…

HotWire／Turbo Frame／Turbo Streamの基本的な挙動は、ここまでで大体、把握できたので、応用編的な位置付けである「Creating a new quote with Turbo Frames」「Ordering our quotes」「Adding a cancel button」および [Chapter 5「Real-time updates with Turbo Streams」](https://www.hotrails.dev/turbo-rails/turbo-streams) 以降は、HotWireを実際に使うタイミングが訪れそうなときに再び検証したいと思います

https://www.hotrails.dev/turbo-rails/turbo-streams

WebSocket相当が無い中で、サーバサイドでのデータ変更を反映する仕組みである「Turbo Streams」をどうやって実装しているのか興味津々です

# 終わりに

HotWireのおおまかな仕組みは理解できました

ReactやVue.js、Svelteあたりに近いイディオムをサーバサイドとして実装しているLiveViewとは、だいぶ異なる構造ですが、元々のRailsのイディオムをあまり変えずに対応できる点は、とてもグッドな機能だと思います

今度、時間のあるときにLiveViewとの比較コラムとかも書いてみたい感じです

なお、過去／現在／未来のスキルから、あなたのBright（輝き）とRight（正しさ）を引き出すElixir製プロダクト「Bright」を先日αリリースしたのですが、次期リリース（5月頃）で「Ruby入門」を搭載予定なので、「HotWire」はスキル科目としてぜひ入れておきたいと考えています

https://bright-fun.org/

現在、下記がアンロックされています

- Elixir入門
- Python入門
- PHP入門
- React入門
- Webアプリ開発 Elixir
- PM（プロジェクトマネージャ）
- WebアプリUIデザイナー
- オウンドメディアマーケター

来年頭に下記がアンロック予定です

- チームリーダー／PL（プロジェクトリーダー）
- PdM（プロダクトマネージャ）
- 新人AWS or AWSプラクティショナー
- 新人Google Cloud or Google Cloudファンダメンタルズ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/6fabf1ea-ac07-7002-e660-a44ba416631c.png)

---

明日は、@buty4649 さんです

# p.s.このコラムが、面白かったり、役に立ったら…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) にて、どうぞ応援よろしくお願いします :bow: