14
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RubyAdvent Calendar 2023

Day 20

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

Last updated at Posted at 2023-12-21

この記事は、Ruby Advent Calendar 2023 シリーズ1 の20日目です

昨日は、 @HAZI さんで 「現在開発中の『SpeedLimiter』Gemの紹介」 でした


piacere です、ご覧いただいてありがとございます :bow:

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

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

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

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

【202312/22追記】
Python/DjangoのHotWireについてもコラム化しました

【202312/23追記】
PHP/Laravelの同種であるLiveWireについてもコラム化しました

あと、このコラムが、面白かったり、役に立ったら、image.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

Chapter 0「Turbo Rails tutorial introduction」

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

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

ブラウザで http://localhost:3000 にアクセスすると、親の顔より良く見たRailsデフォルトページが表示されます
image.png

Chapter 1「A simple CRUD controller with Rails」

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

Creating our Quote model

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

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

Adding our routes and controller actions

コントローラをScaffoldします

bin/rails generate controller Quotes

routerに追加します

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の中身を実装します

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群を追加します

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>
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>
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>
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>
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をインストールします

Gemfile
source "https://rubygems.org"

+gem "simple_form", "~> 5.1.0"
bundle install
bin/rails generate simple_form:install

Show用Viewも追加します

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を起動して確認してみましょう

bin/rails server

テスト用seedデータの追加は割愛するとして、ひとまず、SSR版が動くように成りました
image.png

サンプルデータを突っ込んでおきます
image.png

Chapter 2「Organizing CSS files in Ruby on Rails」

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

Using our CSS architecture on our quote editor

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

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

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);
}
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);
  }
}

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

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);
    }
  }
}
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);
  }
}
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);
    }
  }
}
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;
}
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);
}
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;
  }
}
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);
  }
}
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

Chapter 3「Turbo Drive」

さて、やっと本題です

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

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

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

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

image.png

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

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

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

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

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

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

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

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

んん?…ちゃんとプログレスバー出てるけどなぁ?
image.png

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

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

@import "components/turbo_progress_bar";

うん、プログレスバーが赤色に変わっただけですねw
image.png

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

Chapter 4「Turbo Frames and Turbo Stream templates」

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

What are Turbo Frames?

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

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

この段階だと、特に何も変わりは無いです
image.png

Turbo Frames cheat sheet

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

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

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

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

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

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

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

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>
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

そして登録すると、新規入力部分が、リストになり、新規も追加されていることが確認できるようになります
image.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>

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

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

Editing quotes with Turbo Frames

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

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 でナンバリングされた名前のフレームで囲みます

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

編集ボタンを押すと、該当行のみが編集に切り替わります
image.png

Turbo Frames and the dom_id helper

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

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 %>
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

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

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

image.png

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

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

The Turbo Stream format

削除時のログを見ると、QuotesController#destroy がTurbo Streamとして実行されているようです
image.png

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

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も定義します

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

削除してもエラーにならなくなりました
image.png

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

# 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指定が可能です

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」 以降は、HotWireを実際に使うタイミングが訪れそうなときに再び検証したいと思います

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

終わりに

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

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

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

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

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

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

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

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

image.png


明日は、@buty4649 さんです

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

image.png にて、どうぞ応援よろしくお願いします :bow:

14
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?