algonote(en)

There's More Than One Way To Do It

Yukkuri Hotwire

Take it easy!

Chapter 1: What Hotwire Is

Introduction

In this chapter you get the big picture of Hotwire.

Hotwire is a way to give Rails apps “SPA-like” comfort without building a heavy, JavaScript-first frontend.

If you treat Hotwire as just another handy library, you miss the point. What matters is how it changes how you split responsibilities in the app, and the mindset of keeping HTML at the center while still shipping a modern UX.

You will learn four things in this chapter:

  • What pain points show up in classic Rails vs SPA-style development
  • What the Hotwire idea of HTML over the wire means
  • What Turbo and Stimulus each own
  • Where Hotwire shines and where it is a weaker fit

This chapter keeps code to a minimum and focuses on getting comfortable with the ideas first.


1.1 Pain points in classic Rails and SPAs

Warming up in dialogue

Yukkuri Reimu
“Marisa, people always said Rails was quick and easy to build with—so why do so many projects tack on React or Vue lately?”

Yukkuri Marisa
“That’s the first big theme of this chapter, ze. Old-school Rails meant the server rendered HTML and every screen change was basically a full page reload.”

Yukkuri Reimu
“That sounds simple and nice. What were people unhappy about?”

Yukkuri Marisa
“User experience, ze. Things like updating only one row in a list, opening a modal, or reacting instantly while typing—those smooth, slick UIs were hard.”

Yukkuri Reimu
“So that’s where SPAs came in.”

Yukkuri Marisa
“Right. You build the frontend heavily in JavaScript so navigation and partial updates feel fast and you get a rich experience. But then different problems pile up, ze.”


Strengths and pain points of classic Rails

First, a typical flow in a classic Rails app.

# config/routes.rb
Rails.application.routes.draw do
  resources :posts
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.order(created_at: :desc)
  end

  def show
    @post = Post.find(params[:id])
  end
end
<!-- app/views/posts/index.html.erb -->
<h1>Posts</h1>

<ul>
  <% @posts.each do |post| %>
    <li>
      <%= link_to post.title, post_path(post) %>
    </li>
  <% end %>
</ul>

This setup has real advantages.

- Responsibilities stay easy to group on the server
- Routing → controller → view reads naturally
- SEO-friendly
- Initial render is straightforward
- Forms, submissions, and validation fit the defaults

When you start wanting the things below, you need extra technique.

- Swap only part of a list
- Open a “new” form in a modal
- Avoid a full reload after save
- Reflect other users’ updates in near real time
- Flip UI state the instant a button is pressed

You can still do this in classic Rails with jQuery or vanilla JS, but JS tends to grow per screen and maintenance gets harder.


Strengths and pain points of SPAs

Next, a rough sketch of an SPA-style setup.

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    resources :posts
  end
end
# app/controllers/api/posts_controller.rb
class Api::PostsController < ApplicationController
  def index
    posts = Post.order(created_at: :desc)
    render json: posts
  end
end
// Minimal example suggesting React
import { useEffect, useState } from "react";

export default function Posts() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch("/api/posts")
      .then((response) => response.json())
      .then((data) => setPosts(data));
  }, []);

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

SPAs have clear strengths.

- Strong at partial updates
- Smooth navigation
- Easier to build complex interactions
- UI state is easy to express explicitly in code

For Rails developers, though, the load often includes:

- Frontend and backend drift apart
- You need a JSON API design
- More surfaces for auth and authorization
- Validation and error display get more paths
- SSR and SEO need extra design
- The same data shape ends up maintained in multiple places

Dialogue recap

Yukkuri Reimu
“So Rails stays simple but the UI can feel thin; SPAs feel rich but the architecture gets heavy.”

Yukkuri Marisa
“Exactly, ze. Then people asked: can we get a more modern UI without throwing HTML away? That line of thought became Hotwire.”


Comparison table

+----------------------+------------------------+---------------------------+
| Lens                 | Classic Rails          | SPA                        |
+----------------------+------------------------+---------------------------+
| Where rendering lives| Server generates HTML  | Client renders             |
| Transport            | HTML                   | JSON / API                 |
| Partial updates      | Weak spot              | Strong                     |
| Initial setup        | Simple                 | Somewhat heavier           |
| SEO                  | Strong                 | Needs extra work           |
| State management     | Relatively small       | Tends to grow complex      |
| Where logic lives    | Stays Rails-shaped     | Frontend/backend split     |
+----------------------+------------------------+---------------------------+

1.2 Hotwire’s mindset (HTML over the wire)

Dialogue intro

Yukkuri Reimu
“So what does Hotwire actually do?”

Yukkuri Marisa
“In one line: ‘Let’s send HTML, not JSON,’ ze.”

Yukkuri Reimu
“Wait—isn’t that backwards?”

Yukkuri Marisa
“It looks that way, but it fits Rails absurdly well. If the server can assemble HTML properly, you ship HTML fragments and patch only part of the page. You don’t have to return JSON and rebuild the DOM on the client, ze.”


What HTML over the wire means

In a typical SPA the server returns data as JSON and the browser builds HTML.

[
  { "id": 1, "title": "Learn Hotwire" },
  { "id": 2, "title": "Build a Rails app" }
]

With Hotwire you lean toward the server returning HTML from the start.

<li id="post_1">Learn Hotwire</li>
<li id="post_2">Build a Rails app</li>

That idea is HTML over the wire.

In short:

The server returns not only data but the HTML needed for display
↓
The browser receives that HTML and splices it in
↓
You get partial updates and fast-feeling navigation

Compared to a JSON-first flow

Say you want a new row in the list after creating a post.

JSON-first thinking

render json: { id: @post.id, title: @post.title }
fetch("/posts", {
  method: "POST",
  body: formData
}).then(async (response) => {
  const post = await response.json();

  const li = document.createElement("li");
  li.textContent = post.title;
  document.querySelector("#posts").appendChild(li);
});

HTML over the wire thinking

<!-- app/views/posts/_post.html.erb -->
<li id="<%= dom_id(post) %>">
  <%= post.title %>
</li>
<!-- app/views/posts/create.turbo_stream.erb -->
<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>

Here you are not writing low-level DOM construction on the client.
How things look is concentrated in Rails views.


Why that feels good

The win is that display logic can live on the server again.

- Push HTML assembly toward ERB or ViewComponent
- Cut down on client-only “screen assembly” code
- Reuse server templates as-is
- Keep frontend state management lighter

Especially in Rails, model → controller → view keeps working the way you already know.


Caveats

HTML over the wire is not magic for everything.

- Very heavy client-only state is a poor fit
- Offline-first apps are awkward
- Huge interactive canvases in the browser hit limits

Even so, for line-of-business apps, admin screens, and form-heavy products it is very strong.


Dialogue recap

Yukkuri Reimu
“So Hotwire isn’t ‘push harder on the frontend’—it’s ‘deliver the HTML the server already built, cleverly.’”

Yukkuri Marisa
“Right, ze. You keep what Rails was already good at and still get a modern-feeling UX. That’s the sweet spot.”


1.3 How Turbo and Stimulus split work

Dialogue intro

Yukkuri Reimu
“Isn’t Hotwire a single library?”

Yukkuri Marisa
“In practice the main stars are two: Turbo and Stimulus, ze.”

Yukkuri Reimu
“I hear the names but the roles always blur together.”

Yukkuri Marisa
“That’s what we untangle here. Roughly: Turbo is ‘plumbing for requests and screen updates’; Stimulus is ‘a little extra JavaScript behavior,’ ze.”


What Turbo owns

Turbo covers things like:

- Speeding up full-page navigation
- Making post-submit navigation and updates feel natural
- Replacing only part of the screen
- Applying HTML updates from the server into the DOM

A mental model for what Turbo does:

<a href="/posts/1">Show</a>

Even an ordinary link becomes an accelerated visit under the hood when Turbo is on.

Forms go through Turbo as well.

<%= form_with model: @post do |f| %>
  <%= f.text_field :title %>
  <%= f.submit "Save" %>
<% end %>

You can also wrap just a slice of the page for targeted updates.

<%= turbo_frame_tag "new_post" do %>
  <%= render "form", post: @post %>
<% end %>

Think of Turbo as automation for shipping HTML and merging it into the page.


What Stimulus owns

Stimulus is for adding small bits of JavaScript behavior to HTML.

Example: toggle details open and closed with a button.

<div data-controller="toggle">
  <button data-action="click->toggle#toggle">Toggle</button>

  <div data-toggle-target="content" hidden>
    Hidden content
  </div>
</div>
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["content"];

  toggle() {
    this.contentTarget.hidden = !this.contentTarget.hidden;
  }
}

Unlike React “owning” the whole screen, Stimulus is small controllers wired to HTML.

- Expand / collapse
- Tabs
- Input helpers
- Character counts
- Copy actions
- Richer confirm dialogs

Good home for light interactions.


One-liner on the split

Turbo    = smart round-trips of server HTML
Stimulus = add fine-grained behavior in the browser

Concrete examples

Example 1: append a new post to a list

  • Submit the new-post form
  • Server saves the post
  • Turbo Stream appends HTML at the end of the list

Here Turbo is the lead.

<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>

Example 2: live character count

  • User types in a textarea
  • Show the count inline
  • No server round-trip needed

Here Stimulus is the lead.

<div data-controller="counter">
  <textarea data-action="input->counter#update" data-counter-target="input"></textarea>
  <p><span data-counter-target="output">0</span> characters</p>
</div>
// app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["input", "output"];

  update() {
    this.outputTarget.textContent = this.inputTarget.value.length;
  }
}

Dialogue recap

Yukkuri Reimu
“So Turbo skews toward talking to the server; Stimulus skews toward tiny motions inside the browser.”

Yukkuri Marisa
“That’s a solid mental model, ze. In Hotwire the default order is: ‘Can Turbo solve this?’ Only then do you patch the gaps with Stimulus.”

Yukkuri Reimu
“So the trick is not reaching for all-JavaScript from day one.”

Yukkuri Marisa
“Right, ze. Miss that and you end up with a heavy frontend anyway—even though you chose Hotwire.”


1.4 When to use it—and when not to

Dialogue intro

Yukkuri Reimu
“Listening to all this, Hotwire almost sounds like a silver bullet.”

Yukkuri Marisa
“It is handy, but fit matters, ze. Get that wrong and you pay later.”


Where Hotwire fits well

1. Form-heavy business apps

Examples:

- Admin consoles
- Internal tools
- Task trackers
- CMSs
- Booking systems
- Back-office for e-commerce

Rails forms, validations, and partials shine here.

<%= form_with model: @task do |f| %>
  <% if @task.errors.any? %>
    <ul>
      <% @task.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  <% end %>

  <%= f.text_field :title %>
  <%= f.submit %>
<% end %>

Hotwire’s strength is improving UX while keeping those Rails strengths.


2. Screens with lots of partial updates

- Update a single row in a list
- Swap only the inside of a modal
- Lazy-load tab bodies
- Append new comments to a thread

Turbo Frames and Turbo Streams are a natural fit.

<%= turbo_frame_tag dom_id(task) do %>
  <%= render task %>
<% end %>

3. When you want display logic on the server

If you already invest in ERB, partials, helpers, ViewComponent, etc., Hotwire is very strong.

- Improve existing Rails apps without a rewrite
- Avoid designing a JSON API for everything
- Reduce duplicated presentation rules

Where Hotwire fits less well

1. Extremely complex client-side state

Examples:

- High-end design tools
- Heavy drag-and-drop editors
- Spreadsheet-scale grids
- Offline-first products
- Screens where most state churn happens only in the browser

Here client state management is the star—React or Vue is often the natural pick.


2. When you want a fully split frontend

If you want one backend for:

- Web
- iOS
- Android
- Public external APIs

…a JSON or GraphQL–centric design pays off. Hotwire’s HTML-first story points a different direction than “one API for every client.”


3. Large frontend-only teams

If the org hard-separates frontend and backend, Hotwire’s advantages shrink.

Hotwire especially suits teams that want the server and views close together.


A quick checklist

If many of these are true, Hotwire is a strong candidate:

[ ] You want to reuse Rails template assets
[ ] Admin / operational screens dominate
[ ] Lots of forms and CRUD
[ ] You do not need SPA-grade client state
[ ] You want to keep JavaScript small
[ ] You care about dev speed and maintainability

If many of these are true, tread carefully:

[ ] Huge state kept only in the browser
[ ] Very complex widget UI
[ ] Offline behavior is critical
[ ] Mobile apps assume one shared JSON API
[ ] Frontend-led architecture is a requirement

Dialogue recap

Yukkuri Reimu
“So Hotwire is great at making ordinary web apps feel modern and smooth.”

Yukkuri Marisa
“Yeah, ze—especially friends with Rails CRUD, forms, and server rendering. For ultra client-driven worlds, forcing Hotwire is a bad idea.”

Yukkuri Reimu
“So it’s not ‘Hotwire for everything,’ huh.”

Yukkuri Marisa
“Tech choices aren’t religion—they’re picking the right material for the job.”


Chapter summary

In this chapter you learned the background and mindset behind Hotwire.

  • Classic Rails is simple and powerful, but partial updates and richer UI need extra work
  • SPAs excel at advanced UI, but architecture and responsibility splits get heavy
  • Hotwire’s HTML over the wire idea keeps HTML central while still enabling a modern UX
  • Turbo handles transport and HTML updates; Stimulus handles small JS behaviors
  • Hotwire pairs especially well with form-heavy, CRUD-heavy, business Rails projects

From the next chapter onward you will wire Hotwire into a real Rails project and see Turbo and Stimulus in motion.


Exercises

Question 1

Name at least two UI patterns that classic Rails apps tend to handle worse than SPAs.

Question 2

Explain what HTML over the wire means and how it differs from a JSON-first design.

Question 3

For each task below, should Turbo or Stimulus be the primary tool?

  1. After a form submit, append a new row to a list
  2. Show a live character count for an input
  3. Asynchronously replace part of a list

Question 4

Think of one Rails app you are building now or built in the past.
Is it a better Hotwire app or a better SPA? Briefly say why.


End-of-chapter mini-column: Hotwire is not “the enemy of React”

Yukkuri Reimu
“Does learning Hotwire mean I have to drop React?”

Yukkuri Marisa
“That’s a common misunderstanding, ze. Hotwire is about giving Rails a natural extra option—not about throwing React away.”

Yukkuri Reimu
“So they can coexist?”

Yukkuri Marisa
“Totally, ze. A very normal shape is: Hotwire for most of the app, React only for a few gnarly widgets.”

For example:

- Most of the UI: Rails + Turbo
- One advanced widget: React
- Light DOM tweaks: Stimulus

What matters is not the brand of tech but where you choose to put complexity.
Hotwire is a strong option when you do not want to push all of that complexity onto the client by default.

Chapter 2: Development environment setup

Introduction

Yukkuri Reimu
“We get the ideas from Chapter 1—but how do we actually start?”

Yukkuri Marisa
“This chapter is where you get your hands dirty, ze. You’ll spin up a Rails 7 app with Hotwire wired in and verify Turbo and Stimulus really run.”

Yukkuri Reimu
“So we’re not jumping straight into fancy screens.”

Yukkuri Marisa
“You don’t have to at first. The important thing is checking the environment is right—with the smallest amount of code possible.”

This chapter covers four topics:

  • 2.1 Creating a Rails 7 project with Hotwire enabled
  • 2.2 importmap vs esbuild vs Vite (comparison)
  • 2.3 Verifying the initial Turbo / Stimulus setup
  • 2.4 Tips for a smoother dev workflow

2.1 Creating a Rails 7 project with Hotwire enabled

2.1.1 Start from the smallest setup

Yukkuri Reimu
“Do we have to pick esbuild or Vite from day one?”

Yukkuri Marisa
“For learning, the clearest path is to stay close to Rails defaults, ze. The Rails JavaScript guide presents import maps as the default choice for new apps.” (Ruby on Rails Guides)

Create a new app:

rails new hotwire_sandbox
cd hotwire_sandbox
bin/rails server

With import maps you can run without a separate JS build step—bin/rails server is enough to get going. That’s a big win for learning. (Ruby on Rails Guides)


2.1.2 Creating a Hotwire-ready app explicitly

Depending on templates, spelling it out up front can save confusion:

rails new hotwire_sandbox --javascript=importmap
cd hotwire_sandbox
bin/rails turbo:install stimulus:install
bin/rails server

Yukkuri Reimu
“Are turbo:install and stimulus:install really necessary?”

Yukkuri Marisa
“Sometimes they’re already there, but these commands make the intent—‘turn Hotwire on now’—obvious in the repo. For a book that’s kinder to readers, ze.”


2.1.3 Key generated files

These are the first files to inspect:

app/javascript/application.js
app/javascript/controllers/application.js
app/javascript/controllers/index.js
config/importmap.rb
app/views/layouts/application.html.erb

For example app/javascript/application.js looks like this:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

These two lines matter a lot:

  • Load @hotwired/turbo-rails
  • Load Stimulus controllers through controllers

With Rails you normally use turbo-rails for Turbo integration, and Stimulus controllers under app/javascript/controllers are auto-loaded. (Turbo Handbook)


2.1.4 Checking the layout

application.html.erb must load JavaScript. With import maps it typically looks like:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>HotwireSandbox</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

javascript_importmap_tags injects the import map definition and module loading in one go. (Ruby on Rails Guides)


2.1.5 A tiny home page for smoke testing

bin/rails generate controller Pages home
# config/routes.rb
Rails.application.routes.draw do
  root "pages#home"
end
<!-- app/views/pages/home.html.erb -->
<h1>Hello Hotwire</h1>
<p>It works!</p>
bin/rails server

If the root page renders in the browser, the skeleton is in place.


2.1.6 What this section achieves

The milestone here is still modest:

- You created a Rails app
- You inspected the Hotwire baseline files
- You rendered a minimal page on import maps

Yukkuri Reimu
“It still doesn’t feel very Hotwire-y.”

Yukkuri Marisa
“True, ze—but if the foundation is fuzzy, you’ll get stuck later with ‘Turbo isn’t firing’ or ‘Stimulus never loads.’ Start with the base.”


2.2 importmap vs esbuild vs Vite (comparison)

2.2.1 Bottom line first

Yukkuri Reimu
“So which JS stack should I pick?”

Yukkuri Marisa
“For learning, start with import maps. If npm packages and front-end assets grow in production, consider esbuild or Vite—that’s the usual split, ze.”

The Rails guide positions import maps as the default for new apps: no Node/Yarn required and no separate build step. If you want a bundler, pick esbuild (etc.) via --javascript. (Ruby on Rails Guides)


2.2.2 importmap characteristics

Creating an app

rails new myapp --javascript=importmap

Mental model

# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"

Strengths

- Close to Rails defaults
- Easy to start without Node.js
- No separate build step
- Low learning curve
- Fits small–medium Rails apps well

Weaknesses

- Not ideal if you lean heavily on the npm ecosystem
- Limited for complex front-end asset pipelines
- Can feel tight if TypeScript or advanced front-end work is central

Yukkuri Reimu
“So it’s the ‘Rails-first’ option.”

Yukkuri Marisa
“Right, ze—great fit when you mainly want to understand Hotwire.”


2.2.3 esbuild characteristics

Creating an app

rails new myapp --javascript=esbuild

The Rails guide lists esbuild among bundler choices. (Ruby on Rails Guides)

Mental model

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"

Strengths

- Easier front-end asset management than import maps alone
- Natural npm package usage
- Relatively light builds
- A practical middle ground for “Rails + a bit of modern JS”

Weaknesses

- Requires Node.js
- Slightly heavier for pure learning than import maps
- More places to look when builds fail

2.2.4 Vite characteristics

Vite is not “official Rails core,” but it’s a popular real-world pairing with Rails. Setups using vite_rails / vite_ruby shine for fast HMR during development. We treat it here as a comparison point.
※ To keep the book focused on Hotwire, setup notes stay brief.

Rough setup

bundle add vite_rails
bin/rails vite:install

Strengths

- Excellent front-end developer experience
- Comfortable HMR
- Strong npm ecosystem fit
- Easier path if you later add React / Vue in places

Weaknesses

- A step away from stock Rails
- Slightly harder for beginners to map mentally
- Often overkill for a stage where you “only want Hotwire”

2.2.5 Comparison table

+------------+----------------------+----------------------+----------------------+
| Topic      | importmap            | esbuild              | Vite                 |
+------------+----------------------+----------------------+----------------------+
| Setup cost | Very light           | Light                | Somewhat heavier     |
| Needs Node | No                   | Yes                  | Yes                  |
| Learning   | Very friendly        | Friendly             | More intermediate    |
| npm fit    | Somewhat limited     | Good                 | Very good            |
| “Rails-y”  | Strong               | Medium               | Weaker               |
| Hotwire study | Best fit          | Good                 | Fine but can be much |
+------------+----------------------+----------------------+----------------------+

2.2.6 Policy for this book

We’ll follow this policy:

- Core hands-on uses import maps
- Side notes mention how to think about esbuild / Vite
- Understanding Hotwire itself comes first

Yukkuri Reimu
“If we pile on tools from the start, the Hotwire story gets fuzzy.”

Yukkuri Marisa
“Exactly, ze. The goal isn’t ‘master a build tool’—it’s ‘learn Turbo and Stimulus well.’”


2.3 Verifying the initial Turbo / Stimulus setup

2.3.1 Confirm Turbo is loaded

Turbo intercepts link clicks and form submissions to speed up updates via background fetches. The Turbo Handbook explains that Turbo Drive watches link clicks and form submissions to update without full reloads. (Turbo Handbook)

Create two pages to see Turbo Drive in action:

bin/rails generate controller Pages home about
# config/routes.rb
Rails.application.routes.draw do
  root "pages#home"
  get "about", to: "pages#about"
end
<!-- app/views/pages/home.html.erb -->
<h1>Home</h1>

<p><%= link_to "Go to About", about_path %></p>
<!-- app/views/pages/about.html.erb -->
<h1>About</h1>

<p><%= link_to "Back to Home", root_path %></p>

Check application.js:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

If this is present, Turbo Drive should be active.


2.3.2 Observing Turbo via events

Make invisible behavior visible.
Log Turbo events:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

document.addEventListener("turbo:load", () => {
  console.log("Turbo loaded")
})

document.addEventListener("turbo:visit", (event) => {
  console.log("Turbo visit:", event.detail.url)
})

Open devtools and click a link. You should see something like:

Turbo loaded
Turbo visit: http://localhost:3000/about
Turbo loaded

That confirms navigation is going through Turbo.

Yukkuri Reimu
“Nice—you can see it working.”

Yukkuri Marisa
“Skipping checks like this is risky, ze. Reduce the amount of ‘probably works.’”


2.3.3 Create a Stimulus controller

Next, verify Stimulus. The Stimulus Handbook explains that with Rails, files like [identifier]_controller.js under app/javascript/controllers auto-load, and underscores in filenames map to dashes in HTML identifiers. (Stimulus Handbook)

Create a controller:

// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  connect() {
    console.log("HelloController connected")
    this.outputTarget.textContent = "Stimulus is working!"
  }
}

controllers/index.js usually looks like:

// app/javascript/controllers/index.js
import { application } from "controllers/application"

import HelloController from "./hello_controller"
application.register("hello", HelloController)

View side:

<!-- app/views/pages/home.html.erb -->
<h1>Home</h1>

<div data-controller="hello">
  <p data-hello-target="output">Waiting...</p>
</div>

<p><%= link_to "Go to About", about_path %></p>

On load, Waiting... should become Stimulus is working!


2.3.4 Try a click action

Add a button:

// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  connect() {
    this.outputTarget.textContent = "Stimulus is ready!"
  }

  greet() {
    this.outputTarget.textContent = "Hello from Stimulus!"
  }
}
<!-- app/views/pages/home.html.erb -->
<h1>Home</h1>

<div data-controller="hello">
  <p data-hello-target="output">Waiting...</p>

  <button data-action="click->hello#greet">
    Say hello
  </button>
</div>

The naming map matters:

hello_controller.js   → data-controller="hello"
output target         → data-hello-target="output"
greet method          → click->hello#greet

Stimulus leans heavily on these conventions.


2.3.5 Seeing Turbo and Stimulus together

By now you’ve checked:

  • Page navigation → Turbo
  • Small in-page behavior → Stimulus

Together:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"
<!-- app/views/pages/home.html.erb -->
<div data-controller="hello">
  <p data-hello-target="output">Waiting...</p>
  <button data-action="click->hello#greet">Say hello</button>
</div>

<%= link_to "Go to About", about_path %>

Yukkuri Reimu
“We’re writing JavaScript, but it doesn’t feel like ‘the whole UI is JS.’”

Yukkuri Marisa
“That’s the Hotwire sweet spot, ze—HTML stays central; you only add motion where needed.”


2.3.6 Adding packages with importmap

The Rails guide shows adding external packages with bin/importmap pin. (Ruby on Rails Guides)

Example:

bin/importmap pin lodash

That adds a pin to config/importmap.rb:

pin "lodash"

Use it from JS:

import _ from "lodash"

For the first half of this book we keep external deps small and focus on Turbo and Stimulus.


2.4 Tips for a more productive workflow

2.4.1 Pin the files you touch first

Yukkuri Reimu
“Rails has so many files—I never know where to look.”

Yukkuri Marisa
“Early in Hotwire learning, fix a small set of hotspots—it helps a lot, ze.”

You’ll touch these five constantly:

config/routes.rb
app/controllers/*
app/views/*
app/javascript/application.js
app/javascript/controllers/*

Most early work fits in that slice.


2.4.2 Log whether things “actually ran”

Turbo and Stimulus can both “fail quietly” at first—so log aggressively while learning.

document.addEventListener("turbo:load", () => {
  console.log("turbo:load fired")
})
// Stimulus controller
connect() {
  console.log("connected")
}
# controller
def home
  Rails.logger.info "PagesController#home called"
end
<!-- view -->
<p>Rendered at: <%= Time.current %></p>

Messy checks like this are often the fastest path.


2.4.3 One goal per screen while learning

Bad pattern:

- Try Turbo Drive
- Also add Stimulus
- Also add Tailwind
- Also build a modal

You can’t tell what broke.

Good pattern:

Step 1: Turbo Drive only
Step 2: Stimulus connect only
Step 3: click action only
Step 4: try Turbo Frames

Yukkuri Reimu
“If you pile everything on at once, you can’t bisect the cause.”

Yukkuri Marisa
“Right, ze—for a hands-on book, one clear learning target per chapter matters.”


2.4.4 Know how to temporarily disable Turbo

When debugging, isolating Turbo often saves time.

Per link:

<%= link_to "Full page visit", about_path, data: { turbo: false } %>

Per form:

<%= form_with url: "/search", data: { turbo: false } do |f| %>
  <%= f.text_field :keyword %>
  <%= f.submit "Search" %>
<% end %>

Turbo Drive enhances normal navigation—knowing data-turbo="false" is a useful bisect tool. (Turbo Handbook)


2.4.5 Pages that need a full reload

Hotwire’s docs describe forcing a full reload via turbo-visit-control set to reload; Rails offers a helper. (Turbo Handbook)

Examples: login screens or pages that need special one-time init.

<%# e.g. app/views/layouts/application.html.erb %>
<meta name="turbo-visit-control" content="reload">

Or the Rails helper:

<%= turbo_page_requires_reload %>

Yukkuri Reimu
“So Turbo isn’t ‘always right’—you can fall back to full reloads.”

Yukkuri Marisa
“Exactly, ze—flexibility is part of the toolkit.”


2.4.6 Keep Stimulus controllers small

Anti-pattern:

// stuffing everything into one controller
export default class extends Controller {
  connect() {}
  openModal() {}
  closeModal() {}
  search() {}
  sort() {}
  copy() {}
  preview() {}
  validate() {}
}

Prefer splitting by responsibility:

// modal_controller.js
export default class extends Controller {
  open() {}
  close() {}
}
// counter_controller.js
export default class extends Controller {
  update() {}
}
// clipboard_controller.js
export default class extends Controller {
  copy() {}
}

Stimulus shines as small behavior units attached to HTML, not as a giant app shell—which matches the framework’s design. (Stimulus Handbook)


2.4.7 Keep a tiny dev checklist

A short note at the project root helps beginners:

# notes/hotwire-checklist.md

- Did application.js import turbo-rails?
- Did application.js import controllers?
- Does the layout include javascript_importmap_tags?
- Do Stimulus controller names match data-controller?
- Do targets and actions line up?
- Does disabling Turbo change the symptom?

Chapter summary

Yukkuri Reimu
“So we start light on import maps, prove the smallest Turbo + Stimulus setup, and log until we trust it.”

Yukkuri Marisa
“Yep, ze—here’s the recap:”

- Rails 7 leans on import maps—good for learning Hotwire
- import maps avoid a build step and are easy to start
- esbuild fits npm-heavy, mid-weight production setups
- Vite feels great for DX but is heavier early on
- Turbo speeds navigation and form submissions
- Stimulus adds small client behaviors
- Extra logging early makes invisible failures visible

Exercises

Q1

Name two advantages of an import map–based setup.

Q2

What do these two lines own?

import "@hotwired/turbo-rails"
import "controllers"

Q3

For hello_controller.js, what is the matching data-controller value?

Q4

To call greet on click, what goes in ??

<button data-action="click->hello#?">Hello</button>

Q5

How do you make a link behave as a normal full-page visit to see whether Turbo is involved?


End-of-chapter mini-column: Why this book defaults to import maps

Yukkuri Reimu
“In real jobs Vite feels more ‘2020s,’ though.”

Yukkuri Marisa
“That’s fair—but for a Hotwire learning book, you don’t want build-tool complexity on day one.”

Reasons we default to import maps:

- Easier to focus on Hotwire itself
- Stays close to stock Rails explanations
- Easier to trace “why does this run?”
- Fewer rabbit holes from unrelated build errors

In production, esbuild or Vite are absolutely reasonable when:

- You rely on many npm packages
- TypeScript is a first-class requirement
- You expect partial React / Vue usage
- Front-end DX is a top priority

Yukkuri Marisa
“So: learn on import maps first, widen the toolchain when you need to—that’s a solid path, ze.”

Chapter 3: Building the base app (CRUD)

Introduction

Yukkuri Reimu
“We set up the environment in Chapter 2, but we haven’t done much that feels Hotwire-y yet.”

Yukkuri Marisa
“Right, ze—before Hotwire, you need plain Rails CRUD as a solid base.”

Yukkuri Reimu
“Can’t we jump straight to Turbo Frames and Streams?”

Yukkuri Marisa
“Not ‘can’t,’ but Hotwire sits on Rails. If you skip the vanilla flow, it’s harder to see what Hotwire is simplifying.”

This chapter builds basic CRUD for a task app as the foundation for later Hotwire chapters.

We cover:

  • 3.1 Designing the task app
  • 3.2 CRUD with scaffold
  • 3.3 REST basics and HTML responses
  • 3.4 Tidying layout and partials

3.1 Designing the task app

3.1.1 What we’re building

Yukkuri Reimu
“What kind of app is it?”

Yukkuri Marisa
“A simple task tracker, ze—but ‘simple’ matters. For learning, fewer features make Hotwire’s wins clearer.”

The app will:

- List tasks
- Create tasks
- Show a task
- Edit tasks
- Delete tasks

Minimal fields:

- title       : task name
- description : details
- status      : state
- due_on      : due date

3.1.2 Think model-first

In Rails it helps to name what you manage as a model first.

We use a Task model:

class Task < ApplicationRecord
end

No fancy associations yet—the goal is CRUD on one model.

Yukkuri Reimu
“So no users, projects, memberships from day one.”

Yukkuri Marisa
“Right, ze—those scatter the lesson. First nail Rails CRUD and view structure.”


3.1.3 Decide screens up front

Minimum routes:

GET    /tasks          list
GET    /tasks/:id      show
GET    /tasks/new      new form
POST   /tasks          create
GET    /tasks/:id/edit edit form
PATCH  /tasks/:id      update
DELETE /tasks/:id      destroy

Standard Rails CRUD routing.

Screen mapping:

- index : list
- show  : detail
- new   : new form
- edit  : edit form

Yukkuri Reimu
“That already feels ‘Rails-y.’”

Yukkuri Marisa
“Yep, ze—that regularity is why Rails is learnable—and pairs well with Hotwire.”


3.1.4 Initial status values

We’ll store status as a string with three values for now:

todo
doing
done

You can start without enums; the book may later grow this into an enum.

Mental model:

# Example Task
Task.new(
  title: "Learn Hotwire",
  description: "Read chapter 3 and build CRUD",
  status: "todo",
  due_on: Date.today + 7
)

3.1.5 Sketch navigation before code

Happy path for create:

List
  ↓ “New Task”
New form
  ↓ “Create Task”
Show
  ↓ “Back to tasks”
List

Edit flow:

Show
  ↓ “Edit this task”
Edit form
  ↓ “Update Task”
Show

Destroy:

Show
  ↓ “Delete”
List

Deciding where each action lands keeps controller code steady.


3.1.6 Section recap

Model:
- Task

Attributes:
- title
- description
- status
- due_on

Screens:
- index
- show
- new
- edit

Operations:
- create
- update
- destroy

Yukkuri Reimu
“‘Design’ sounds huge, but just listing what to build already changes a lot.”

Yukkuri Marisa
“Rails tempts you to code fast—skip the list and the repo gets messy, ze.”


3.2 CRUD with scaffold

3.2.1 Why scaffold

Yukkuri Reimu
“So we actually build—with scaffold?”

Yukkuri Marisa
“For learning, scaffold first gives you the whole map quickly, ze.”

scaffold generates:

- migration
- model
- controller
- views
- routes
- tests

Commands:

bin/rails generate scaffold Task title:string description:text status:string due_on:date
bin/rails db:migrate
bin/rails server

3.2.2 Inspect what was generated

Important paths:

app/models/task.rb
app/controllers/tasks_controller.rb
app/views/tasks/index.html.erb
app/views/tasks/show.html.erb
app/views/tasks/new.html.erb
app/views/tasks/edit.html.erb
app/views/tasks/_form.html.erb
config/routes.rb
db/migrate/XXXXXXXXXXXXXX_create_tasks.rb

config/routes.rb gains:

Rails.application.routes.draw do
  resources :tasks
end

Yukkuri Reimu
“One line for full CRUD?”

Yukkuri Marisa
resources is that strong, ze.”


3.2.3 Read the migration

# db/migrate/xxxxxxxxxxxxxx_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :tasks do |t|
      t.string :title
      t.text :description
      t.string :status
      t.date :due_on

      t.timestamps
    end
  end
end

Resulting columns:

id
title
description
status
due_on
created_at
updated_at

timestamps is everywhere in Rails.


3.2.4 Read the model

Generated:

# app/models/task.rb
class Task < ApplicationRecord
end

Add minimal validations:

# app/models/task.rb
class Task < ApplicationRecord
  validates :title, presence: true
  validates :status, presence: true
end

Console check:

bin/rails console
task = Task.new
task.valid?
# => false

task.errors.full_messages
# => ["Title can't be blank", "Status can't be blank"]

Yukkuri Reimu
“Small checks like this matter.”

Yukkuri Marisa
“Browser-only testing hides what’s happening, ze.”


3.2.5 Read the controller

Scaffold controllers are long but complete:

# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  before_action :set_task, only: %i[ show edit update destroy ]

  def index
    @tasks = Task.all
  end

  def show
  end

  def new
    @task = Task.new
  end

  def edit
  end

  def create
    @task = Task.new(task_params)

    if @task.save
      redirect_to @task, notice: "Task was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @task.update(task_params)
      redirect_to @task, notice: "Task was successfully updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @task.destroy
    redirect_to tasks_url, notice: "Task was successfully destroyed."
  end

  private

    def set_task
      @task = Task.find(params[:id])
    end

    def task_params
      params.require(:task).permit(:title, :description, :status, :due_on)
    end
end

Key ideas:

- Clear per-action responsibilities
- Different paths for success vs failure
- Strong parameters limit mass assignment

3.2.6 Look at the index view

<!-- app/views/tasks/index.html.erb -->
<p style="color: green"><%= notice %></p>

<h1>Tasks</h1>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render task %>
    <p>
      <%= link_to "Show this task", task %>
    </p>
  <% end %>
</div>

<%= link_to "New task", new_task_path %>

Yukkuri Reimu
render task looks like shorthand.”

Yukkuri Marisa
“It renders the partial _task.html.erb for that record, ze.”

<!-- app/views/tasks/_task.html.erb -->
<div id="<%= dom_id task %>">
  <p>
    <strong>Title:</strong>
    <%= task.title %>
  </p>

  <p>
    <strong>Description:</strong>
    <%= task.description %>
  </p>

  <p>
    <strong>Status:</strong>
    <%= task.status %>
  </p>

  <p>
    <strong>Due on:</strong>
    <%= task.due_on %>
  </p>
</div>

dom_id task becomes crucial in later Hotwire chapters.


3.2.7 Seed a few rows

Visit /tasks in the browser and create sample data, e.g.:

Title: Learn Hotwire
Description: Finish chapter 3
Status: todo
Due on: 2026-04-10
Title: Build Turbo Frame example
Description: Prepare chapter 5 sample
Status: doing
Due on: 2026-04-12

If the list shows them, baseline CRUD is done.


3.3 REST basics and HTML responses

3.3.1 What REST means here

Yukkuri Reimu
“People say ‘RESTful’ all the time—what is it?”

Yukkuri Marisa
“Roughly: give URLs and HTTP verbs consistent meaning so operations stay organized, ze.”

Task operations:

+-----------+-------------+----------------------+----------------+
| Action    | HTTP verb   | Path                 | Controller     |
+-----------+-------------+----------------------+----------------+
| List      | GET         | /tasks               | index          |
| Show      | GET         | /tasks/:id           | show           |
| New form  | GET         | /tasks/new           | new            |
| Create    | POST        | /tasks               | create         |
| Edit form | GET         | /tasks/:id/edit      | edit           |
| Update    | PATCH/PUT   | /tasks/:id           | update         |
| Destroy   | DELETE      | /tasks/:id           | destroy        |
+-----------+-------------+----------------------+----------------+

That table is why Rails flows read predictably.


3.3.2 Inspect routes

bin/rails routes -g task

Example output:

     tasks GET    /tasks(.:format)          tasks#index
           POST   /tasks(.:format)          tasks#create
  new_task GET    /tasks/new(.:format)      tasks#new
 edit_task GET    /tasks/:id/edit(.:format) tasks#edit
      task GET    /tasks/:id(.:format)      tasks#show
           PATCH  /tasks/:id(.:format)      tasks#update
           PUT    /tasks/:id(.:format)      tasks#update
           DELETE /tasks/:id(.:format)      tasks#destroy

Yukkuri Reimu
“So this is where task_path / tasks_path come from.”

Yukkuri Marisa
“When routing confuses you, start here, ze.”


3.3.3 Rails as HTML responses

This chapter stresses that Rails returns HTML by default.

index:

def index
  @tasks = Task.all
end

Rails implicitly renders:

app/views/tasks/index.html.erb

Flow:

Controller sets instance variables
↓
Matching HTML template renders
↓
Browser receives HTML

That pipeline is what later becomes “HTML over the wire.”


3.3.4 Walking through create

def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

Steps:

1. Read submitted values
2. Build a Task
3. Try to save
4. On success, redirect to show
5. On failure, re-render new with 422

“Redirect on success, re-render on failure” is a Rails staple.


3.3.5 Strong parameters

def task_params
  params.require(:task).permit(:title, :description, :status, :due_on)
end

Only permitted keys are applied—even if the form posts extra fields.

Yukkuri Reimu
“So assignment is explicit.”

Yukkuri Marisa
“Open mass assignment is dangerous, ze.”


3.3.6 How form_with behaves

<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
  <% if task.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>

      <ul>
        <% task.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title, style: "display: block" %>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :description, style: "display: block" %>
    <%= form.text_area :description %>
  </div>

  <div>
    <%= form.label :status, style: "display: block" %>
    <%= form.text_field :status %>
  </div>

  <div>
    <%= form.label :due_on, style: "display: block" %>
    <%= form.date_field :due_on %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

form_with(model: task) picks URL and verb from whether the record is new:

New record:
POST /tasks

Existing record:
PATCH /tasks/:id

Classic Rails ergonomics.


3.3.7 Browser-level flow (create)

GET  /tasks/new
  ↓
Show form

POST /tasks
  ↓
Success → 302 redirect → GET /tasks/:id
Failure → 422 Unprocessable Entity + re-render new

Being able to replay this in your head makes Turbo changes easier later.


3.4 Tidying layout and partials

3.4.1 Scaffold views are plain

Yukkuri Reimu
“Scaffold is handy but looks bare.”

Yukkuri Marisa
“Design isn’t the point yet—learning to structure views is, ze.”

A little cleanup now pays off when you Hotwire-ify.


3.4.2 Touch up application layout

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>TaskApp</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <header>
      <nav>
        <%= link_to "TaskApp", tasks_path %> |
        <%= link_to "New Task", new_task_path %>
      </nav>
    </header>

    <% if notice.present? %>
      <p style="color: green"><%= notice %></p>
    <% end %>

    <% if alert.present? %>
      <p style="color: red"><%= alert %></p>
    <% end %>

    <main>
      <%= yield %>
    </main>
  </body>
</html>

Shared nav and flash in the layout.

Yukkuri Reimu
“You moved notice into the layout.”

Yukkuri Marisa
“Cleaner than repeating it on every template, ze.”


3.4.3 Clean up index

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

Add _task_card.html.erb:

<!-- app/views/tasks/_task_card.html.erb -->
<section class="task-card">
  <h2><%= link_to task.title, task %></h2>

  <p><strong>Status:</strong> <%= task.status %></p>
  <p><strong>Due:</strong> <%= task.due_on %></p>

  <% if task.description.present? %>
    <p><%= task.description %></p>
  <% end %>
</section>

You’re separating list presentation from detail presentation.


3.4.4 Clean up show

<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>

<div class="task-detail">
  <p>
    <strong>Description:</strong><br>
    <%= simple_format(@task.description) %>
  </p>

  <p>
    <strong>Status:</strong>
    <%= @task.status %>
  </p>

  <p>
    <strong>Due on:</strong>
    <%= @task.due_on %>
  </p>
</div>

<div class="actions">
  <%= link_to "Edit this task", edit_task_path(@task) %> |
  <%= link_to "Back to tasks", tasks_path %>

  <hr>

  <%= button_to "Delete this task", @task, method: :delete %>
</div>

simple_format helps multiline descriptions read better.


3.4.5 Shared form for new / edit

new.html.erb:

<!-- app/views/tasks/new.html.erb -->
<h1>New task</h1>

<%= render "form", task: @task %>

<br>

<div>
  <%= link_to "Back to tasks", tasks_path %>
</div>

edit.html.erb:

<!-- app/views/tasks/edit.html.erb -->
<h1>Edit task</h1>

<%= render "form", task: @task %>

<br>

<div>
  <%= link_to "Show this task", @task %> |
  <%= link_to "Back to tasks", tasks_path %>
</div>

The body lives in _form.html.erb—basic partial split.


3.4.6 status as a <select>

# app/models/task.rb
class Task < ApplicationRecord
  STATUSES = %w[todo doing done].freeze

  validates :title, presence: true
  validates :status, presence: true, inclusion: { in: STATUSES }
end
<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
  <% if task.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>

      <ul>
        <% task.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title, style: "display: block" %>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :description, style: "display: block" %>
    <%= form.text_area :description %>
  </div>

  <div>
    <%= form.label :status, style: "display: block" %>
    <%= form.select :status, Task::STATUSES.map { |status| [status.humanize, status] } %>
  </div>

  <div>
    <%= form.label :due_on, style: "display: block" %>
    <%= form.date_field :due_on %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

Yukkuri Reimu
“Fewer typos this way.”

Yukkuri Marisa
“A little view polish goes a long way, ze.”


3.4.7 How to think about partials

Useful split:

_form.html.erb      : shared new/edit form
_task.html.erb      : generic task render
_task_card.html.erb : list-optimized render

You don’t need infinite splits—repeated UI belongs in partials.
Hotwire partial updates line up naturally with partial boundaries.


3.4.8 View layout at this point

app/views/tasks/
  index.html.erb
  show.html.erb
  new.html.erb
  edit.html.erb
  _form.html.erb
  _task.html.erb
  _task_card.html.erb

This structure keeps Turbo Frames and Streams readable in later chapters.


Chapter summary

Yukkuri Reimu
“Okay—I see why plain Rails CRUD comes before Hotwire.”

Yukkuri Marisa
“Recap, ze:”

- Designed a simple Task model for learning
- Used scaffold to spin up full CRUD quickly
- Confirmed RESTful routes via resources
- Rails primarily returns HTML templates
- create/update: redirect on success, re-render on failure
- Partial splits (_form, cards) tidy the UI
- That tidiness pays off when Hotwire arrives

Exercises

Q1

List the four attributes on Task.

Q2

What base paths does resources :tasks generate?

Q3

On create failure, why render :new instead of redirect_to?

Q4

Why is form_with(model: task) convenient?

Q5

How should files be arranged if new and edit share one form?


End-of-chapter mini-column: Why start from scaffold

Yukkuri Reimu
“Some people say you never use scaffold in production, so skip learning it.”

Yukkuri Marisa
“Half true, half wrong, ze.”

Production code rarely stays “raw scaffold,” but for early learning scaffold is valuable:

- See the standard CRUD shape in one shot
- Trace model → controller → views → routes
- Get to a running baseline fast
- Then delete, trim, and reshape

The point is not treating scaffold as the finish line:

Scaffold for a skeleton
↓
Read the code
↓
Remove noise
↓
Shape it to your needs
↓
Grow Hotwire features on top

Yukkuri Marisa
“Hand-writing everything teaches too—but scaffold grabs the big picture fastest, ze.”

Chapter 4: Speeding up navigation with Turbo Drive

Introduction

Yukkuri Reimu
“We have a normal CRUD app now, but it still doesn’t scream ‘Hotwire is amazing!’”

Yukkuri Marisa
“From Chapter 4 we finally meet Turbo—Hotwire’s star. First up: Turbo Drive, the foundation.”

Yukkuri Reimu
“What does Turbo Drive actually do?”

Yukkuri Marisa
“In short: it intercepts link clicks and form submits so you don’t throw away the whole page on every round-trip, ze.”

Yukkuri Reimu
“So it still looks like a normal Rails app, but feels a bit SPA-ish.”

Yukkuri Marisa
“Right—and you change surprisingly little. That’s why understanding what really happens matters.”

This chapter covers:

  • 4.1 How Turbo Drive works
  • 4.2 What changes for links
  • 4.3 Form submission behavior
  • 4.4 Caching and gotchas

4.1 How Turbo Drive works

4.1.1 What Turbo Drive is

Yukkuri Reimu
“I want the mechanics first.”

Yukkuri Marisa
“Good instinct, ze. Turbo Drive doesn’t let the browser handle navigation blindly—it catches clicks/submits in JS, fetches HTML in the background, and swaps what needs swapping (centered on <body>).”

Normal navigation:

Click link
  ↓
Browser navigates to new URL
  ↓
Current page torn down
  ↓
New HTML arrives
  ↓
Whole page rebuilt

With Turbo Drive:

Click link
  ↓
Turbo handles the event
  ↓
Fetch HTML in the background
  ↓
Replace <body> (and related pieces)
  ↓
Feels like navigation

The point: it looks like classic navigation, but the plumbing is smarter.


4.1.2 Check application.js

The line that turns Turbo Drive on:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

Importing @hotwired/turbo-rails wires Turbo to links and forms.

Yukkuri Reimu
“One line does that much?”

Yukkuri Marisa
“Yep—which is both convenient and easy to not notice.”


4.1.3 What actually gets replaced

Turbo Drive is not a full SPA renderer. It still trusts server HTML—it doesn’t rebuild the whole UI in client-side JS.

Example show:

# app/controllers/tasks_controller.rb
def show
end
<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>

<p><strong>Status:</strong> <%= @task.status %></p>
<p><strong>Due on:</strong> <%= @task.due_on %></p>

<%= link_to "Back to tasks", tasks_path %>

With Turbo Drive the server still returns ordinary HTML. What changes is how the browser applies that HTML.


4.1.4 Observe Turbo Drive with events

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

document.addEventListener("turbo:click", (event) => {
  console.log("turbo:click", event.target)
})

document.addEventListener("turbo:before-visit", (event) => {
  console.log("turbo:before-visit", event.detail.url)
})

document.addEventListener("turbo:visit", (event) => {
  console.log("turbo:visit", event.detail.url)
})

document.addEventListener("turbo:load", () => {
  console.log("turbo:load")
})

Open devtools, go from /tasks to a task. You should see:

turbo:click <a href="/tasks/1">...</a>
turbo:before-visit http://localhost:3000/tasks/1
turbo:visit http://localhost:3000/tasks/1
turbo:load

Yukkuri Reimu
“Nice—I can see Turbo working.”

Yukkuri Marisa
“Better than only noticing ‘feels faster,’ ze.”


4.1.5 Compared to full reloads

Without Turbo Drive, JS state, DOM, and the whole page reset on navigation.

With Turbo Drive, roughly:

- Turbo intercepts clicks
- HTML is fetched (fetch-like)
- New HTML is parsed
- Title and body update
- Completion events fire

That’s why it often feels lighter than a full reload.


4.1.6 Remember: not an SPA

Yukkuri Reimu
“It’s starting to feel SPA-ish.”

Yukkuri Marisa
“Careful—Turbo Drive is not an SPA engine. It smartens up an HTML-first MPA.”

SPA:
- JSON in, client renders
- Client owns state

Turbo Drive:
- HTML in, merge into page
- Server templates own presentation

4.2 Link clicks

Yukkuri Reimu
“Do I need special link markup?”

Yukkuri Marisa
“Usually no—that’s the big win, ze.”

Index + partial:

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

<%= link_to "New task", new_task_path %>
<!-- app/views/tasks/_task_card.html.erb -->
<section class="task-card">
  <h2><%= link_to task.title, task_path(task) %></h2>

  <p><strong>Status:</strong> <%= task.status %></p>
  <p><strong>Due:</strong> <%= task.due_on %></p>
</section>

Those plain links are accelerated by Turbo Drive.


4.2.2 Add a crude loading indicator

Layout:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>TaskApp</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <div id="loading-indicator" style="display: none;">
      Loading...
    </div>

    <header>
      <nav>
        <%= link_to "TaskApp", tasks_path %> |
        <%= link_to "New Task", new_task_path %>
      </nav>
    </header>

    <% if notice.present? %>
      <p style="color: green"><%= notice %></p>
    <% end %>

    <main>
      <%= yield %>
    </main>
  </body>
</html>

JS:

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

const showLoading = () => {
  const indicator = document.getElementById("loading-indicator")
  if (indicator) indicator.style.display = "block"
}

const hideLoading = () => {
  const indicator = document.getElementById("loading-indicator")
  if (indicator) indicator.style.display = "none"
}

document.addEventListener("turbo:before-visit", showLoading)
document.addEventListener("turbo:load", hideLoading)

Yukkuri Reimu
“So Turbo ‘steals’ the click—that’s visible now.”

Yukkuri Marisa
“You can polish the UI later; first feel the mechanism, ze.”


4.2.3 Compare with Turbo disabled

<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>

<p><strong>Status:</strong> <%= @task.status %></p>
<p><strong>Due on:</strong> <%= @task.due_on %></p>

<p>
  <%= link_to "Back to tasks (Turbo ON)", tasks_path %>
</p>

<p>
  <%= link_to "Back to tasks (Turbo OFF)", tasks_path, data: { turbo: false } %>
</p>

data-turbo="false" → normal browser navigation (full reload).

Turbo ON:
- Turbo handles the event
- Faster, merged navigation

Turbo OFF:
- Native navigation
- Full reload

4.2.4 When you want classic navigation

Examples where data: { turbo: false } helps:

- Leaving for external sites
- Pages needing special one-time init
- Temporary debugging
- Conflicts with certain JS libraries
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>

Yukkuri Reimu
“So we don’t Turbo everything blindly.”

Yukkuri Marisa
“Being able to opt out is huge in real apps, ze.”


4.2.5 Helpers stay the same

<%= link_to "Tasks", tasks_path %>
<%= link_to "New Task", new_task_path %>
<%= link_to @task.title, task_path(@task) %>

Views stay classic Rails—that’s why adoption is easy.


4.3 Form submissions

4.3.1 Forms are Turbo-managed too

Yukkuri Reimu
“Not just links—forms too?”

Yukkuri Marisa
“Yes—and in CRUD apps that’s often where you feel the benefit most, ze.”

<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
  <% if task.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(task.errors.count, "error") %> prohibited this task from being saved:</h2>

      <ul>
        <% task.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title, style: "display: block" %>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :description, style: "display: block" %>
    <%= form.text_area :description %>
  </div>

  <div>
    <%= form.label :status, style: "display: block" %>
    <%= form.select :status, Task::STATUSES.map { |status| [status.humanize, status] } %>
  </div>

  <div>
    <%= form.label :due_on, style: "display: block" %>
    <%= form.date_field :due_on %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

Plain form_with is Turbo Drive–aware.


4.3.2 Successful create

# app/controllers/tasks_controller.rb
def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end
POST /tasks
  ↓
Save succeeds
  ↓
redirect_to @task
  ↓
GET /tasks/:id

Turbo follows that redirect smoothly.

Yukkuri Reimu
“So server code barely changes.”

Yukkuri Marisa
“That’s the design—Turbo Drive amplifies existing Rails habits, ze.”


4.3.3 Failed create

def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end
POST /tasks
  ↓
Save fails
  ↓
render :new
  ↓
Form re-renders with values + errors

Turbo still handles this naturally from the user’s perspective.


4.3.4 Log submit events

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

document.addEventListener("turbo:submit-start", (event) => {
  console.log("turbo:submit-start", event.target)
})

document.addEventListener("turbo:submit-end", (event) => {
  console.log("turbo:submit-end", event.detail)
})

Example log:

turbo:submit-start <form ...>
turbo:submit-end { formSubmission: ..., success: true, fetchResponse: ... }

On validation errors the success / response details differ—handy when you move to Frames/Streams.


4.3.5 Disable submit while in flight

<!-- app/views/tasks/_form.html.erb -->
<div>
  <%= form.submit "Save task", id: "task-submit-button" %>
</div>
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

document.addEventListener("turbo:submit-start", () => {
  const button = document.getElementById("task-submit-button")
  if (button) {
    button.disabled = true
    button.value = "Saving..."
  }
})

document.addEventListener("turbo:submit-end", () => {
  const button = document.getElementById("task-submit-button")
  if (button) {
    button.disabled = false
    button.value = "Save task"
  }
})

Yukkuri Reimu
“Small UX wins without a big frontend stack.”

Yukkuri Marisa
“Exactly, ze.”


4.3.6 Turbo-off forms for comparison

<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task, data: { turbo: false }) do |form| %>
  ...
<% end %>

Classic form post + full reload.

Turbo ON: intercepted, smoother transitions
Turbo OFF: native submit, reload-oriented

Default stance: keep Turbo on unless you have a reason.


4.3.7 POST-redirect-GET still matters

if @task.save
  redirect_to @task, notice: "Task was successfully created."
else
  render :new, status: :unprocessable_entity
end
Success:
- Move URL to the new resource
- Safe on refresh

Failure:
- Re-show the form with input + errors

Hotwire or not, this Rails pattern stays canonical.


4.4 Caching and caveats

4.4.1 Turbo caches pages

Yukkuri Reimu
“Turbo Drive isn’t only ‘faster fetches,’ right?”

Yukkuri Marisa
“It also caches pages to make back/forward feel snappier, ze.”

/tasks
  ↓
/tasks/1
  ↓
Browser Back

Turbo may restore a cached snapshot—handy, but you may briefly see stale DOM if you aren’t expecting it.


4.4.2 Ephemeral DOM tweaks

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<p id="temporary-message"></p>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>
document.addEventListener("turbo:load", () => {
  const message = document.getElementById("temporary-message")
  if (message) {
    message.textContent = "Welcome back!"
  }
})

First visit vs revisit vs back-button can look slightly different—plan for that.


4.4.3 Prefer turbo:load over only DOMContentLoaded

Anti-pattern:

document.addEventListener("DOMContentLoaded", () => {
  console.log("This may run only once")
})

Better in Turbo apps:

document.addEventListener("turbo:load", () => {
  console.log("This runs after each Turbo visit")
})

Yukkuri Reimu
“Navigation doesn’t always mean a full reload—so init hooks change.”

Yukkuri Marisa
“Major footgun for Turbo newcomers, ze.”


4.4.4 Avoid piling duplicate listeners

Risky pattern:

document.addEventListener("turbo:load", () => {
  const button = document.getElementById("danger-button")
  button.addEventListener("click", () => {
    alert("clicked")
  })
})

Each visit can stack another listener. Safer quick fix:

document.addEventListener("turbo:load", () => {
  const button = document.getElementById("danger-button")
  if (!button) return

  button.onclick = () => {
    alert("clicked")
  }
})

More Hotwire-native: move micro-behavior to Stimulus (Turbo for navigation, Stimulus for local UI).


4.4.5 Pages you don’t want cached

- Must always be fresh
- Heavy third-party init
- Security-sensitive back-button behavior
<!-- app/views/tasks/special.html.erb -->
<%= turbo_page_requires_reload %>

<h1>Special page</h1>
<p>This page always requires a full reload.</p>

4.4.6 Asset changes and data-turbo-track

<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

Use data-turbo-track="reload" on assets that should force a refresh when they change. If CSS/JS updates feel “sticky,” check this.


4.4.7 Debugging playbook

1. Watch turbo:* logs
2. Try data-turbo="false" to compare behavior
3. Ensure init isn’t DOMContentLoaded-only
4. Consider Stimulus for page-local JS
5. Consider turbo_page_requires_reload if needed

Yukkuri Reimu
“So don’t blame Turbo first—check events and init timing.”

Yukkuri Marisa
“Turbo is convenient, but full-reload assumptions linger and bite you, ze.”


Chapter summary

Yukkuri Reimu
“Turbo Drive: looks like normal Rails navigation, smarter underneath.”

Yukkuri Marisa
“Solid model. Chapter 4 recap:”

- Turbo Drive intercepts links/forms for faster visits
- Server still returns normal HTML
- Plain link_to / form_with work with Turbo
- redirect on success, render on failure—still the Rails baseline
- Prefer turbo:load over DOMContentLoaded-only init
- Understand caching to explain odd flashes
- Disable Turbo temporarily to bisect issues

Exercises

Q1

Name two things Turbo Drive accelerates.

Q2

Which single line above enables Turbo Drive?

import "@hotwired/turbo-rails"
import "controllers"

Q3

How do you make a link a normal browser visit?

Q4

What event should usually run per-page setup in Turbo apps?

Q5

For the create action shown, what happens on success vs failure?

def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

End-of-chapter mini-column: Turbo Drive is “boring” but foundational

Yukkuri Reimu
“Frames and Streams look flashier.”

Yukkuri Marisa
“True—but Drive matters a ton, ze. It lifts the whole app without you building complex UI.”

- Drops into existing Rails apps easily
- link_to and form_with keep working
- Keeps HTML-on-the-server design
- Benefits even before fancy partial updates

Yukkuri Marisa
“Frames and Streams stack on Drive—nail Drive first and later chapters get easier, ze.”

Chapter 5: Partial updates with Turbo Frames

Introduction

Yukkuri Reimu “I get Turbo Drive. It’s the thing that makes link clicks and form submissions a little smarter, right?”

Yukkuri Marisa “That’s right, ze. But with Turbo Drive alone, you’re still basically doing full-page navigation. The next step is Turbo Frames, which lets you swap out just one part of the screen.”

Yukkuri Reimu “Oh, it’s starting to feel a bit SPA-like.”

Yukkuri Marisa “It can look that way. Under the hood it’s still Rails HTML, though. In other words, Turbo Frames is about replacing a specific slice of server-rendered HTML on purpose.”

This chapter covers four topics.

  • 5.1 What Turbo Frames are
  • 5.2 Splitting list and detail
  • 5.3 Building a modal UI
  • 5.4 Nesting and pitfalls

5.1 What Turbo Frames are

5.1.1 The basic idea of Turbo Frames

Yukkuri Reimu “First off—what do Turbo Frames wrap?”

Yukkuri Marisa “The answer is simple: the part of the screen you want to replace later.”

Turbo Frames wrap a piece of HTML like this.

<%= turbo_frame_tag "task_details" do %>
  <p>Select a task</p>
<% end %>

That gives you an update target named task_details.

Conceptually, it looks like this.

Full page
├─ Header
├─ Sidebar
├─ List
└─ task_details frame ← only this part gets swapped

So instead of refreshing the whole page, you replace only a specific region with HTML.


5.1.2 A minimal example first

Suppose you want to switch only the task detail area.

On the index page you might write:

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<ul>
  <% @tasks.each do |task| %>
    <li>
      <%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
    </li>
  <% end %>
</ul>

<%= turbo_frame_tag "task_details" do %>
  <p>Please select a task.</p>
<% end %>

The show page looks like this.

<!-- app/views/tasks/show.html.erb -->
<%= turbo_frame_tag "task_details" do %>
  <h2><%= @task.title %></h2>

  <p><strong>Status:</strong> <%= @task.status %></p>
  <p><strong>Due on:</strong> <%= @task.due_on %></p>
  <p><%= @task.description %></p>
<% end %>

Now when you click a link in the list, only the contents of the task_details frame update—not the entire page.

Yukkuri Reimu “Ooh, that feels pretty good.”

Yukkuri Marisa “Yeah, ze. The key point is the server is just returning normal HTML.”


5.1.3 Returning “the same frame id” matters

With Turbo Frames, the response must also include a frame with the same name.

The correspondence looks like this.

List side:
data-turbo-frame="task_details"

Response side:
<turbo-frame id="task_details"> ... </turbo-frame>

In Rails, turbo_frame_tag lets you express that naturally.

<%= turbo_frame_tag "task_details" do %>
  ...
<% end %>

If you forget this, partial updates won’t behave as expected.


5.1.4 Compared to not using frames

With Turbo Drive alone, the task detail link looked like this.

<%= link_to task.title, task_path(task) %>

That triggers a normal full-page navigation.

With Turbo Frames you write:

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

Summarizing the difference:

Turbo Drive:
- Smart navigation at full-page granularity

Turbo Frames:
- Targeted replacement of part of the page

Yukkuri Reimu “So Frames are a finer-grained layer on top of Drive.”

Yukkuri Marisa “That’s a solid way to put it, ze.”


5.1.5 When frames shine

Turbo Frames are especially strong in situations like these.

- List and detail side by side on one screen
- Replace only a form
- Update only a side panel
- Load modal content
- Swap tab bodies asynchronously without a full reload

Conversely, you don’t need to force Frames where a full-page transition is enough.


5.2 Splitting list and detail

5.2.1 Trying a two-column UI

Yukkuri Reimu “The clearest place Frames shine is probably a list-and-detail side-by-side UI.”

Yukkuri Marisa “Exactly, ze. Let’s reshape the task app into list on the left, detail on the right.”

First, change index like this.

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div class="tasks-layout">
  <section class="tasks-list">
    <ul>
      <% @tasks.each do |task| %>
        <li>
          <%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
        </li>
      <% end %>
    </ul>

    <p>
      <%= link_to "New task", new_task_path, data: { turbo_frame: "task_details" } %>
    </p>
  </section>

  <section class="tasks-detail">
    <%= turbo_frame_tag "task_details" do %>
      <p>Select a task to see the details.</p>
    <% end %>
  </section>
</div>

Add a little CSS as well.

/* app/assets/stylesheets/application.css */
.tasks-layout {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 24px;
  align-items: start;
}

.tasks-list {
  border-right: 1px solid #ddd;
  padding-right: 16px;
}

.tasks-detail {
  min-height: 240px;
}

You now have list and detail on one screen.


5.2.2 Shaping show for the detail panel

Next, adjust show.html.erb for frame-based replacement.

<!-- app/views/tasks/show.html.erb -->
<%= turbo_frame_tag "task_details" do %>
  <h2><%= @task.title %></h2>

  <p>
    <strong>Status:</strong>
    <%= @task.status %>
  </p>

  <p>
    <strong>Due on:</strong>
    <%= @task.due_on %>
  </p>

  <p>
    <strong>Description:</strong><br>
    <%= simple_format(@task.description) %>
  </p>

  <div class="actions">
    <%= link_to "Edit", edit_task_path(@task), data: { turbo_frame: "task_details" } %>
    |
    <%= link_to "Back to list", tasks_path %>
  </div>
<% end %>

The important bit is that Edit also targets the same frame.


5.2.3 Making new and edit frame-aware

Show the new-task form in the right-hand panel as well.

<!-- app/views/tasks/new.html.erb -->
<%= turbo_frame_tag "task_details" do %>
  <h2>New task</h2>

  <%= render "form", task: @task %>
<% end %>

Do the same for edit.

<!-- app/views/tasks/edit.html.erb -->
<%= turbo_frame_tag "task_details" do %>
  <h2>Edit task</h2>

  <%= render "form", task: @task %>

  <p>
    <%= link_to "Show task", task_path(@task), data: { turbo_frame: "task_details" } %>
  </p>
<% end %>

5.2.4 Behavior after save

The controller can mostly stay as-is.

# app/controllers/tasks_controller.rb
def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

def update
  if @task.update(task_params)
    redirect_to @task, notice: "Task was successfully updated."
  else
    render :edit, status: :unprocessable_entity
  end
end

The idea: after a form submit inside Turbo Frames, if the redirect target’s show also returns the same frame, navigation finishes entirely inside that frame.

Yukkuri Reimu “Not having to rewrite the controller wholesale is seriously powerful.”

Yukkuri Marisa “Right, ze. Turbo Frames are genuinely good at partial updates without breaking the usual Rails flow.”


5.2.5 Polishing the list a bit

Add a list partial so selection is clearer.

<!-- app/views/tasks/_task_link.html.erb -->
<li id="<%= dom_id(task, :link) %>">
  <%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>
  <small>(<%= task.status %>)</small>
</li>

index becomes:

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div class="tasks-layout">
  <section class="tasks-list">
    <ul>
      <% @tasks.each do |task| %>
        <%= render "task_link", task: task %>
      <% end %>
    </ul>

    <p>
      <%= link_to "New task", new_task_path, data: { turbo_frame: "task_details" } %>
    </p>
  </section>

  <section class="tasks-detail">
    <%= turbo_frame_tag "task_details" do %>
      <p>Select a task to see the details.</p>
    <% end %>
  </section>
</div>

This also plays nicely with Turbo Streams later.


5.2.6 Separating list vs. detail responsibilities

What matters here is keeping list concerns and detail concerns separate.

List:
- Choose a task
- Start creating a new one

Detail:
- View task content
- Edit
- Other detail-level actions

That separation makes it easier to evolve toward modals or side panels.


5.3 Building a modal UI

5.3.1 The Turbo Frames approach to modals

Yukkuri Reimu “I get list and detail—but can you do modals with Frames too?”

Yukkuri Marisa “You can, ze. It’s one of the classic Frames patterns. The idea is simple: put an empty frame in the layout that will hold the modal content.”

First, add a modal region to the layout.

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>TaskApp</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <header>
      <nav>
        <%= link_to "TaskApp", tasks_path %>
      </nav>
    </header>

    <% if notice.present? %>
      <p style="color: green"><%= notice %></p>
    <% end %>

    <main>
      <%= yield %>
    </main>

    <%= turbo_frame_tag "modal" %>
  </body>
</html>

This modal frame is what will receive modal content later.


5.3.2 Opening “New task” in a modal

Change the link on the index.

<!-- app/views/tasks/index.html.erb -->
<p>
  <%= link_to "New task in modal", new_task_path, data: { turbo_frame: "modal" } %>
</p>

The response from new_task_path then injects the frame with id="modal".


5.3.3 Making new.html.erb modal-ready

Write new.html.erb like this.

<!-- app/views/tasks/new.html.erb -->
<%= turbo_frame_tag "modal" do %>
  <div class="modal-backdrop">
    <div class="modal-window">
      <h2>New task</h2>

      <%= render "form", task: @task %>

      <p>
        <%= link_to "Close", tasks_path, data: { turbo_frame: "_top" } %>
      </p>
    </div>
  </div>
<% end %>

Add CSS:

/* app/assets/stylesheets/application.css */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  display: grid;
  place-items: center;
}

.modal-window {
  background: white;
  padding: 24px;
  width: min(600px, 90vw);
  border-radius: 12px;
}

The new-task form now opens in a modal-like overlay.

Yukkuri Reimu “Whoa, that actually looks the part!”

Yukkuri Marisa “Right, ze. And you’re not building modal DOM with JavaScript—you’re plugging in HTML from the server as-is.”


5.3.4 What _top means

This bit matters:

data: { turbo_frame: "_top" }

_top means navigate as the full page, not inside the frame.

For example, the modal Close link:

<%= link_to "Close", tasks_path, data: { turbo_frame: "_top" } %>

That way you return to /tasks as a full-page visit instead of updating only the modal frame.


5.3.5 Closing the modal after a successful save

A common request:

Yukkuri Reimu “After the form saves successfully, I want the modal to close and go back to the list.”

Yukkuri Marisa “Fair. Let’s start with the simplest approach: on success, navigate the whole page.”

You can branch in the controller, but if you keep the view simple first, having the post-submit destination be the list or detail is often enough.

A more natural “close only the modal” flow comes through cleanly in the Turbo Streams chapter. This chapter focuses on getting modals open with Frames first.


5.3.6 Edit modals work the same way

Edit follows the same pattern.

<!-- app/views/tasks/show.html.erb -->
<%= turbo_frame_tag "task_details" do %>
  <h2><%= @task.title %></h2>

  <p><strong>Status:</strong> <%= @task.status %></p>
  <p><strong>Due on:</strong> <%= @task.due_on %></p>

  <p>
    <%= link_to "Edit in modal", edit_task_path(@task), data: { turbo_frame: "modal" } %>
  </p>
<% end %>

edit.html.erb can look like this:

<!-- app/views/tasks/edit.html.erb -->
<%= turbo_frame_tag "modal" do %>
  <div class="modal-backdrop">
    <div class="modal-window">
      <h2>Edit task</h2>

      <%= render "form", task: @task %>

      <p>
        <%= link_to "Close", task_path(@task), data: { turbo_frame: "_top" } %>
      </p>
    </div>
  </div>
<% end %>

5.3.7 When modal-style Frames fit

Turbo Frame modals are especially handy for:

- New forms
- Edit forms
- Confirmation-style detail views
- Auxiliary UI without breaking the list layout

For animations, focus management, and Esc-to-close, pairing with Stimulus is natural. For this chapter, grasping that Frames alone can feel plenty modal-like is enough.


5.4 Nesting and pitfalls

5.4.1 Frames are handy—but too many get confusing

Yukkuri Reimu “Frames are pretty great. I’m tempted to frame everything on the screen now.”

Yukkuri Marisa “I get that urge—that’s the trap, ze.”

Turbo Frames are useful, but if you add too many you get:

- Hard to see which link updates which frame
- Uncertainty about which frame ids the response must include
- Blurry responsibility for what gets partially updated
- Nested frames that are painful to follow

5.4.2 Seeing a nested example

You can end up with structures like this.

<%= turbo_frame_tag "task_details" do %>
  <h2><%= @task.title %></h2>

  <%= turbo_frame_tag "comments" do %>
    <p>No comments yet</p>
  <% end %>
<% end %>

Technically that works. But links and forms get harder to reason about.

For example:

<%= link_to "Load comments", comments_task_path(@task), data: { turbo_frame: "comments" } %>

You might want to update only the inner frame. If that pattern multiplies, readers suffer.


5.4.3 Don’t use vague frame names

A bad example:

<%= turbo_frame_tag "content" do %>
  ...
<% end %>

Vague names like content or main make it unclear what belongs there later.

Prefer names that describe the role:

<%= turbo_frame_tag "task_details" do %>
  ...
<% end %>

<%= turbo_frame_tag "modal" do %>
  ...
<% end %>

<%= turbo_frame_tag dom_id(@task) do %>
  ...
<% end %>

Yukkuri Reimu “So the name should tell you what the frame displays.”

Yukkuri Marisa “Exactly, ze. Be kind to future-you maintaining this.”


5.4.4 The trap of mismatched frame responses

A frequent mistake:

List side:

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

…but the response looks like:

<%= turbo_frame_tag "details" do %>
  ...
<% end %>

The frame names don’t match.

It should be:

<%= turbo_frame_tag "task_details" do %>
  ...
<% end %>

The name you target for updates must match the frame id in the response.


Another important point: links and forms inside a frame tend to stay inside that frame unless you say otherwise.

Say you write this inside a modal:

<%= turbo_frame_tag "modal" do %>
  <p>Task created.</p>
  <%= link_to "Go to tasks", tasks_path %>
<% end %>

Without options, that link tries to refresh the modal frame with /tasks—you can end up with the list embedded inside the modal.

Avoid that with _top:

<%= link_to "Go to tasks", tasks_path, data: { turbo_frame: "_top" } %>

That matters a lot.


5.4.6 Checking validation error behavior

When using Frames with forms, always verify failure behavior.

If create fails:

def create
  @task = Task.new(task_params)

  if @task.save
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

If new.html.erb is wrapped in a frame, the form re-renders with errors inside that frame.

<%= turbo_frame_tag "modal" do %>
  <h2>New task</h2>
  <%= render "form", task: @task %>
<% end %>

That’s naturally good UX—which is why forms and Frames mesh well.


5.4.7 How far to push Frames

Finally, guidelines for when to use them.

Frames are a fit:

- Replace one meaningful region
- Keep list + detail together
- Swap only a form
- Load modal content

Prefer fewer frames when:

- You’re switching entire screen context at once
- The update boundary is fuzzy
- It’s unclear which region is primary
- Nesting gets too deep

Yukkuri Reimu “So it’s not ‘Frames everywhere’—it’s about meaningful update units.”

Yukkuri Marisa “Right, ze. Frames are convenient, but your design judgment shows straight through.”


Chapter summary

Yukkuri Reimu “Turbo Frames feel really practical—not the whole page, just ‘I want to update this part’—they map to that urge really well.”

Yukkuri Marisa “Yeah, ze. Summing up this chapter:”

- Turbo Frames swap part of the page with HTML
- data-turbo-frame on link_to targets a specific frame to update
- The response must include a turbo_frame_tag with the same id
- Great fit for list-and-detail on one screen
- Easy to load modal content as a frame
- _top navigates outside the frame—i.e. the full page
- Too many frames blur responsibility and hurt maintainability
- Frame names should reflect their role

Exercises

Question 1

Explain the difference between Turbo Drive and Turbo Frames.

Question 2

Which frame does the following link try to update?

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

Question 3

For Turbo Frames partial updates to succeed, what must the response include?

Question 4

When you want a link inside a modal to navigate the full page, what do you set data-turbo-frame to?

Question 5

What problems can crop up if you use Turbo Frames too aggressively? Give at least two.


End-of-chapter sidebar: Turbo Frames as “just-right” partial updates

Yukkuri Reimu “It’s surprising you can go this far without assembling everything on the client like an SPA.”

Yukkuri Marisa “Right, ze. Frames shine because you can SPA-like behavior only where you need it.”

Fully client-rendered apps trade freedom for complexity. Turbo Frames answer urges like:

- Update only this part
- Replace only this form
- Keep the list and show detail
- Fetch only modal HTML from the server

So Frames sit between full-page navigation and a full SPA.

Yukkuri Marisa “Frames might be where Hotwire’s appeal is easiest to feel.”


Chapter 6: Real-time updates with Turbo Streams

Introduction

Yukkuri Reimu “Turbo Drive sped up page transitions, and Turbo Frames let us do partial updates. But ‘real-time updates’ are a different story again?”

Yukkuri Marisa “Right. Turbo Streams is the piece that lets you inject, replace, or remove HTML fragments while a page or frame is on screen.

Yukkuri Reimu “Oh—it’s starting to feel more ‘live,’ isn’t it.”

Yukkuri Marisa “Yeah. And what’s interesting is HTML is still the star. Instead of building the DOM hands-on on the client, the server returns HTML that tells the browser what DOM operations to perform—that’s Turbo Streams.”

This chapter covers the following four topics.

  • 6.1 Turbo Streams basics
  • 6.2 Automatic reflection of create/update/destroy
  • 6.3 Syncing across multiple users
  • 6.4 Working with Action Cable

6.1 Turbo Streams basics

6.1.1 What Turbo Streams is

Yukkuri Reimu “First off, how is Turbo Streams different from Frames?”

Yukkuri Marisa “Good question. Roughly like this.”

Turbo Frames:
- Replace one frame region wholesale

Turbo Streams:
- Apply operations to the DOM such as append / prepend / replace / update / remove

So Frames feel like “swap the whole box,” while Streams feel like “issue fine-grained instructions for what’s inside the box.”


6.1.2 A minimal append example

Say you want to append a new task to the end of a task list.

Set up the index view like this.

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

<p>
  <%= link_to "New task", new_task_path %>
</p>

The partial for displaying a task.

<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
  <h2><%= link_to task.title, task_path(task) %></h2>
  <p><strong>Status:</strong> <%= task.status %></p>
  <p><strong>Due:</strong> <%= task.due_on %></p>
</section>

Then add create.turbo_stream.erb.

<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>

This appends the new task HTML to the end of the element with id="tasks".

Yukkuri Reimu “Wait—I don’t have to write JavaScript like appendChild?”

Yukkuri Marisa “Nope. Being able to express that in a Rails view is the satisfying part of Turbo Streams.”


6.1.3 Seeing what Turbo Stream does in HTML

Rails helpers can obscure this, so it helps to picture the raw shape.

Conceptually, turbo_stream.append "tasks", ... is close to a response like this.

<turbo-stream action="append" target="tasks">
  <template>
    <section id="task_123" class="task-card">
      <h2>Learn Turbo Streams</h2>
      <p><strong>Status:</strong> todo</p>
      <p><strong>Due:</strong> 2026-04-15</p>
    </section>
  </template>
</turbo-stream>

When the browser sees this <turbo-stream>, it does the following.

Find the element matching target="tasks"
↓
Apply the template contents according to action

6.1.4 Common actions at a glance

These are the operations you’ll use most often with Turbo Streams.

append   : add as a child at the end
prepend  : add as a child at the beginning
replace  : replace the target element itself
update   : swap only the inner HTML of the target
remove   : remove the target element
before   : insert immediately before the target
after    : insert immediately after the target

In Rails you can write:

<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.remove @task %>

When you pass @task, Rails resolves the target id using dom_id(@task).


6.1.5 What enables create.turbo_stream.erb

For Turbo Streams, the request must arrive as turbo_stream format. In a Hotwire setup, Turbo-aware forms and certain flows often make that happen naturally.

In the controller, respond_to keeps things clear.

# app/controllers/tasks_controller.rb
def create
  @task = Task.new(task_params)

  respond_to do |format|
    if @task.save
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully created." }
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

With this in place:

  • Turbo Stream requests → create.turbo_stream.erb
  • Ordinary HTML requests → redirect

6.1.6 Start with “one more row on the list”

Yukkuri Reimu “Turbo Streams can do so much I’m not sure where to begin.”

Yukkuri Marisa “Start here—that’s enough at first.”

1. Give the list id="tasks"
2. Build a partial for each row
3. In create.turbo_stream.erb, append

Once this minimal setup clicks, update and destroy follow naturally.


6.2 Automatic reflection of create/update/destroy

6.2.1 Reflecting create on the list immediately

Start by having new records show up on the list right away.

For simplicity, embed the form on index.

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<%= render "form", task: Task.new %>

<hr>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

The form:

<!-- app/views/tasks/_form.html.erb -->
<%= form_with(model: task) do |form| %>
  <% if task.errors.any? %>
    <div style="color: red">
      <ul>
        <% task.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title %><br>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :description %><br>
    <%= form.text_area :description %>
  </div>

  <div>
    <%= form.label :status %><br>
    <%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
  </div>

  <div>
    <%= form.label :due_on %><br>
    <%= form.date_field :due_on %>
  </div>

  <div>
    <%= form.submit "Create task" %>
  </div>
<% end %>

The controller:

# app/controllers/tasks_controller.rb
def index
  @tasks = Task.order(created_at: :desc)
end

def create
  @task = Task.new(task_params)

  respond_to do |format|
    if @task.save
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully created." }
    else
      format.html { render :index, status: :unprocessable_entity }
    end
  end
end

create.turbo_stream.erb:

<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>

The new task appears at the top of the list the moment it’s created.


6.2.2 Resetting the form after create

Yukkuri Reimu “It’s nice that it was added, but leftover values in the form are a bit annoying.”

Yukkuri Marisa “Streams handle that too—replace the form region along with the list.”

First wrap the form in its own region.

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<div id="task_form">
  <%= render "form", task: Task.new %>
</div>

<hr>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

Then update create.turbo_stream.erb like this.

<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>

In one shot you:

  • add the new task to the list
  • reset the form to empty

Yukkuri Reimu “Updating two places in one response is pretty powerful.”

Yukkuri Marisa “That’s the tasty part of Streams in real apps.”


6.2.3 Reflecting update in place

Next, replace only that task’s markup when it’s updated.

Add an edit link on task_card.

<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
  <h2><%= task.title %></h2>
  <p><strong>Status:</strong> <%= task.status %></p>
  <p><strong>Due:</strong> <%= task.due_on %></p>

  <p>
    <%= link_to "Edit", edit_task_path(task) %>
  </p>
</section>

Handle the update like this:

# app/controllers/tasks_controller.rb
def update
  respond_to do |format|
    if @task.update(task_params)
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully updated." }
    else
      format.html { render :edit, status: :unprocessable_entity }
    end
  end
end

Add update.turbo_stream.erb.

<!-- app/views/tasks/update.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

Only that task’s DOM node is replaced.


6.2.4 Inline edit forms the Hotwire way

For a more Hotwire-native update flow, swap the row for a form and swap back after save.

Create an edit partial, for example.

<!-- app/views/tasks/_edit_form.html.erb -->
<section id="<%= dom_id(task) %>">
  <%= form_with(model: task) do |form| %>
    <div>
      <%= form.text_field :title %>
    </div>

    <div>
      <%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
    </div>

    <div>
      <%= form.date_field :due_on %>
    </div>

    <div>
      <%= form.submit "Save" %>
    </div>
  <% end %>
</section>

You can use edit.turbo_stream.erb to turn that row into a form in place.

<!-- app/views/tasks/edit.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/edit_form", locals: { task: @task } %>

This is a very Hotwire-flavored pattern.


6.2.5 Reflecting destroy immediately

Deletion is straightforward.

Add a delete button to the partial.

<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
  <h2><%= task.title %></h2>
  <p><strong>Status:</strong> <%= task.status %></p>
  <p><strong>Due:</strong> <%= task.due_on %></p>

  <p>
    <%= button_to "Delete", task_path(task), method: :delete %>
  </p>
</section>

The controller:

# app/controllers/tasks_controller.rb
def destroy
  @task.destroy

  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to tasks_path, notice: "Task was successfully destroyed." }
  end
end

The view:

<!-- app/views/tasks/destroy.turbo_stream.erb -->
<%= turbo_stream.remove @task %>

After delete, that row vanishes from the list without a full reload.

Yukkuri Reimu “That feels really good—not ‘reload and see one fewer,’ it just disappears.”

Yukkuri Marisa “Yeah. Streams nail that ‘in place’ feeling.”


6.2.6 create/update/destroy at a glance

The basic patterns so far boil down to this.

create:
- prepend / append onto the list
- replace the form too if needed

update:
- replace the target row

destroy:
- remove the target row

In code terms, the correspondence looks like:

<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.remove @task %>

These three are your starter set.


6.3 Syncing across multiple users

6.3.1 Partial updates for one user aren’t the whole story

Yukkuri Reimu “So far this only updates my own screen, right?”

Yukkuri Marisa “Exactly. But Turbo Streams really shines from here: you can push someone else’s changes to everyone currently viewing the page.

Imagine the same task list open in two browsers.

Browser A: /tasks
Browser B: /tasks

If A adds a task, it’s satisfying when B’s list updates automatically. Turbo Streams + broadcasting is how you do that.


6.3.2 Wire up a subscription first

Subscribe to a stream on the index view.

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<%= turbo_stream_from "tasks" %>

<div id="task_form">
  <%= render "form", task: Task.new %>
</div>

<hr>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

turbo_stream_from "tasks" is the important line. It sets this page up to receive the "tasks" stream.

Yukkuri Reimu “That’s all it takes to subscribe?”

Yukkuri Marisa “Yep. Then the server pushes updates onto that stream.”


6.3.3 Broadcasting from the model

Starting with model callbacks keeps things easy to follow.

# app/models/task.rb
class Task < ApplicationRecord
  STATUSES = %w[todo doing done].freeze

  validates :title, presence: true
  validates :status, presence: true, inclusion: { in: STATUSES }

  after_create_commit -> { broadcast_prepend_to "tasks" }
  after_update_commit -> { broadcast_replace_to "tasks" }
  after_destroy_commit -> { broadcast_remove_to "tasks" }
end

So:

  • on create → prepend to the "tasks" stream
  • on update → replace
  • on destroy → remove

all fire automatically.


6.3.4 Which HTML broadcast uses

Broadcasts generally use the partial for the record—e.g. for Task, _task.html.erb or whatever partial you specify.

If your list uses _task_card.html.erb, naming it explicitly is clearer.

# app/models/task.rb
after_create_commit -> {
  broadcast_prepend_to "tasks",
    target: "tasks",
    partial: "tasks/task_card",
    locals: { task: self }
}

after_update_commit -> {
  broadcast_replace_to "tasks",
    partial: "tasks/task_card",
    locals: { task: self }
}

after_destroy_commit -> {
  broadcast_remove_to "tasks"
}

target: "tasks" is the parent element for prepend.


6.3.5 Try it with two browsers

Open /tasks in two tabs or two browser windows.

Browser A

Open /tasks

Browser B

Open /tasks

Create a task in A

Title: Sync test
Status: todo

Browser B should receive the new task automatically.

Yukkuri Reimu “Whoa—that really feels real-time.”

Yukkuri Marisa “And you never hand-rolled WebSocket JSON for it.”


6.3.6 Broadcasting vs. updates to yourself

A practical caveat:

Right after submitting the form, your own screen can get updates from both:

  • the controller’s create.turbo_stream.erb
  • the model’s after_create_commit broadcast

which can duplicate the new row.

In other words:

Turbo Stream response for your request
+
Turbo Stream from broadcast
=
risk of the same row appearing twice

Align your design to avoid that.

Approach A: controller for you, broadcast for everyone else

Approach B: broadcast-only for everyone

While learning, splitting roles mentally helps.


6.3.7 In production: who sees which update

This chapter broadcasts "tasks" to everyone; in real apps you often narrow the audience.

- Task list per project
- Notifications per signed-in user
- Updates per team

Then you shape stream names accordingly.

<%= turbo_stream_from [@project, "tasks"] %>

Match that on the model side.

broadcast_prepend_to [project, "tasks"], target: "tasks"

People tied to that project then receive only those updates.


6.4 Working with Action Cable

6.4.1 What powers Turbo Streams real-time delivery

Yukkuri Reimu “You keep saying ‘subscribe to a stream’ and ‘broadcast’—what’s actually running under the hood?”

Yukkuri Marisa “That’s Action Cable. Rails’ built-in WebSocket stack underpins Turbo Streams’ real-time delivery.”

Roughly:

Browser subscribes via turbo_stream_from
↓
Action Cable keeps the WebSocket open
↓
Server calls broadcast_*
↓
Subscribed browsers receive Turbo Stream messages
↓
DOM updates

So the real-time slice of Turbo Streams sits on Action Cable.


6.4.2 Check Action Cable routing

Rails usually mounts Action Cable.

# config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => "/cable"

  resources :tasks
  root "tasks#index"
end

The browser opens a WebSocket to /cable.


6.4.3 Check the import side too

With turbo-rails loaded, wiring tends to line up.

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

In the stock Rails setup, Hotwire also brings in Action Cable. So early on you often don’t need to touch Cable’s low-level API directly.


6.4.4 Dev checklist when real-time “does nothing”

When sync fails, check:

- DevTools: is the WebSocket connected?
- Is it hitting /cable?
- Does the page include turbo_stream_from?
- Are broadcast_* calls firing from models or controllers?
- Do dev logs show Action Cable activity?

You might see lines like:

Started GET "/cable" [WebSocket]
Successfully upgraded to WebSocket
Registered connection
TasksChannel is streaming from tasks

Exact wording varies by environment; whether the WebSocket connects is the first gate.


6.4.5 You often don’t need a custom Channel at first

Yukkuri Reimu “Action Cable makes me think I have to write something like TasksChannel myself.”

Yukkuri Marisa “Classic Cable tutorials give that impression. With Turbo Streams you often skip that at the start.”

turbo_stream_from plus broadcast_* covers a lot; Rails handles the plumbing.

So for learning, it’s enough to:

Learn what Action Cable is
↓
But start with the Turbo Streams APIs

6.4.6 Why you should still keep Cable in mind

You can’t ignore it entirely. In production you’ll care about things like:

- Does WebSocket work in your deployment topology?
- How do you fan out across processes / servers?
- How do you use Redis or another pub/sub backend?
- Who is authorized to receive what?

You don’t need to go deep in a first book, but remembering real-time Turbo Streams rides on Action Cable pays off later.


6.4.7 Controller-led vs model-led

A final split of styles.

Controller-led

def create
  @task = Task.new(task_params)

  if @task.save
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to tasks_path }
    end
  end
end

Model-led

after_create_commit -> { broadcast_prepend_to "tasks" }

Roughly:

Controller-led:
- Easy to see what this request returns
- One-user updates are straightforward

Model-led:
- Broadcast on persist events
- Pairs well with multi-user sync

Yukkuri Reimu “So picking the right tool matters.”

Yukkuri Marisa “Yeah. Start with the controller for mental model; add model broadcasts when you need real-time sync for everyone.”


Chapter summary

Yukkuri Reimu “Turbo Streams is pretty neat. The idea of expressing DOM work in HTML finally clicks.”

Yukkuri Marisa “That’s the important intuition. This chapter in bullet form:”

- Turbo Streams updates the DOM via append / replace / remove, etc.
- The server returns turbo-stream responses; the browser applies the instructions
- For create use prepend/append; update use replace; destroy use remove
- One response can touch multiple regions
- turbo_stream_from subscribes to a stream
- broadcast_* can fan the same update to many users
- Action Cable sits under the real-time path
- Compare controller-led updates with model-led broadcasts by role

Exercises

Question 1

Explain the difference between Turbo Frames and Turbo Streams.

Question 2

When you want a new task at the beginning of a list with id="tasks", which helper is the natural choice?

Question 3

What does the following code do?

<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

Question 4

What view helper subscribes so the same update can reach multiple users’ screens?

Question 5

Which Rails built-in feature underpins Turbo Streams real-time delivery?


End-of-chapter mini-column: “DOM work moves back toward the server”

Yukkuri Reimu “Until now, ‘on every add/update/delete, tweak the DOM in JavaScript’ felt normal.”

Yukkuri Marisa “Hotwire flips that. With Turbo Streams you express DOM intent on the server.”

Traditional thinking:

Receive JSON
↓
Build DOM nodes in JavaScript
↓
Append

With Turbo Streams:

Server builds an HTML fragment
↓
Return it with append / replace / remove instructions
↓
Browser applies them

So UI update responsibility shifts back toward server templates—which fits many Rails apps.

Yukkuri Marisa “Once Turbo Streams clicks, you really feel you can go far without React.”

Chapter 7: Front-end control with Stimulus

Introduction

Yukkuri Reimu “We’ve gotten through Turbo Drive, Turbo Frames, and Turbo Streams, and I’m starting to feel like that covers everything.”

Yukkuri Marisa “I get the feeling—but with that alone you’ll hit situations where ‘fine-grained UI behavior’ is missing. That’s where Stimulus comes in.”

Yukkuri Reimu “So it’s the layer for adding a bit of JavaScript?”

Yukkuri Marisa “Exactly. In Hotwire, the baseline split is: big updates with Turbo, small behaviors with Stimulus.”

This chapter covers four topics:

  • 7.1 The philosophy of Stimulus
  • 7.2 Controller basics
  • 7.3 DOM manipulation and events
  • 7.4 Coexistence patterns with Turbo

7.1 The philosophy of Stimulus

7.1.1 Stimulus is “modest JavaScript”

Yukkuri Reimu “How is it different from frameworks like React or Vue?”

Yukkuri Marisa “Quite different. Stimulus isn’t about ‘ruling the whole screen’—it’s for ‘adding a little on top of HTML’.”

Side by side it looks like this:

React / Vue:
- Build the UI in JS
- State management is central
- Virtual DOM and similar are the stars

Stimulus:
- Add behavior to existing HTML
- Server-rendered HTML is the star
- Binds directly to the DOM

7.1.2 The HTML-first mindset

The overall Hotwire idea is:

1. Build HTML on the server
2. Update it with Turbo
3. Fill gaps only where needed with Stimulus

So Stimulus is about

Not “building everything in JS”
But “giving HTML meaning and behavior”

7.1.3 Defining behavior with data attributes

A hallmark of Stimulus is declaring behavior on the HTML side.

<div data-controller="hello">
  <button data-action="click->hello#greet">Click me</button>
</div>

The JavaScript looks like this:

// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  greet() {
    alert("Hello Stimulus!")
  }
}

Yukkuri Reimu “So the HTML spells out which JS gets called.”

Yukkuri Marisa “Right. That way you can read ‘where what happens’ without jumping across files as much.”


7.1.4 Summary of Stimulus’s role

- Handles small UI interactions
- Stays tightly coupled to HTML
- Fills in fine control that Turbo doesn’t do
- Assumes you split things into small pieces

7.2 Controller basics

7.2.1 Creating a Controller

Start with a simple controller.

// app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["output"]

  increment() {
    const current = Number(this.outputTarget.textContent)
    this.outputTarget.textContent = current + 1
  }
}

The HTML side:

<div data-controller="counter">
  <p data-counter-target="output">0</p>

  <button data-action="click->counter#increment">
    +1
  </button>
</div>

7.2.2 Controller structure

The basic shape is:

export default class extends Controller {
  static targets = ["name"]

  connect() {
    // init
  }

  action() {
    // event handling
  }
}

There are three important pieces:

controller: data-controller
targets   : data-xxx-target
actions   : data-action

7.2.3 The role of connect()

connect() {
  console.log("connected")
}

This runs when the controller attaches to the DOM.

In a Turbo environment, the call chain is:

page navigation
↓
turbo:load
↓
Stimulus connect

7.2.4 Using targets

static targets = ["input", "output"]

HTML:

<input data-counter-target="input">
<p data-counter-target="output"></p>

JS:

this.inputTarget.value
this.outputTarget.textContent

7.2.5 Multiple targets

static targets = ["item"]

HTML:

<li data-controller="list">
  <span data-list-target="item">A</span>
  <span data-list-target="item">B</span>
</li>

JS:

this.itemTargets.forEach((el) => {
  console.log(el.textContent)
})

7.2.6 Holding state with values

static values = { count: Number }

increment() {
  this.countValue++
}

HTML:

<div data-controller="counter" data-counter-count-value="0">

This is a lightweight form of state management.


7.2.7 Style control with classes

static classes = ["active"]

toggle() {
  this.element.classList.toggle(this.activeClass)
}

7.3 DOM manipulation and events

7.3.1 Character count for input

// app/javascript/controllers/text_counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "output"]

  update() {
    this.outputTarget.textContent = this.inputTarget.value.length
  }
}
<div data-controller="text-counter">
  <textarea data-text-counter-target="input"
            data-action="input->text-counter#update"></textarea>

  <p>
    Characters: <span data-text-counter-target="output">0</span>
  </p>
</div>

7.3.2 Toggle UI

// toggle_controller.js
export default class extends Controller {
  static targets = ["content"]

  toggle() {
    this.contentTarget.hidden = !this.contentTarget.hidden
  }
}
<div data-controller="toggle">
  <button data-action="click->toggle#toggle">Toggle</button>

  <div data-toggle-target="content" hidden>
    Hidden content
  </div>
</div>

7.3.3 Toggling CSS classes

toggle() {
  this.element.classList.toggle("is-active")
}

7.3.4 Event types

data-action="
  click->controller#method
  input->controller#method
  submit->controller#method
"

7.3.5 preventDefault

submit(event) {
  event.preventDefault()
}

7.3.6 Debounce-style handling

update() {
  clearTimeout(this.timeout)

  this.timeout = setTimeout(() => {
    console.log("debounced")
  }, 300)
}

7.3.7 DOM manipulation recap

- textContent
- value
- classList
- hidden
- insertAdjacentHTML

7.4 Coexistence patterns with Turbo

7.4.1 Splitting responsibilities between Turbo and Stimulus

Yukkuri Reimu “I’ve got a decent handle on this now, but what’s the right way to split things with Turbo?”

Yukkuri Marisa “The reliable mental model is this.”

Turbo:
- Data updates
- HTML replacement

Stimulus:
- Fine UI behavior
- Immediate feedback

7.4.2 Turbo adds markup; Stimulus adds motion

Example: highlight when a new task is added

// highlight_controller.js
export default class extends Controller {
  connect() {
    this.element.classList.add("highlight")

    setTimeout(() => {
      this.element.classList.remove("highlight")
    }, 1000)
  }
}
<section id="<%= dom_id(task) %>"
         data-controller="highlight">

7.4.3 Stimulus reconnects after Turbo updates

HTML added via Turbo Stream
↓
Inserted into the DOM
↓
Stimulus connect() runs

This is an important point.


7.4.4 Forms + Stimulus + Turbo

// disable_submit_controller.js
export default class extends Controller {
  static targets = ["button"]

  disable() {
    this.buttonTarget.disabled = true
  }
}
<form data-controller="disable-submit"
      data-action="submit->disable-submit#disable">

  <button data-disable-submit-target="button">
    Submit
  </button>
</form>

7.4.5 Using Turbo events

document.addEventListener("turbo:load", () => {
  console.log("page loaded")
})

7.4.6 Common anti-patterns

❌ Building too much of the DOM in Stimulus
❌ Ignoring Turbo and reaching for fetch
❌ Letting a single controller grow huge

7.4.7 A feel for the right usage

Data updates → Turbo
UI polish    → Stimulus

Chapter summary

Yukkuri Reimu “Stimulus feels ‘low-key but super important,’ huh.”

Yukkuri Marisa “That’s the idea.”

- Stimulus is lightweight JS that adds behavior to HTML
- The trio controller / target / action is the foundation
- Keep DOM manipulation simple
- It really shines combined with Turbo

Exercises

Question 1

What are the differences between Stimulus and React?

Question 2

In the following code, which method runs on click?

<button data-action="click->counter#increment">

Question 3

Explain the role of targets.

Question 4

How do Turbo and Stimulus divide responsibilities?

Question 5

When is connect() invoked?


End-of-chapter mini-column: Stimulus works best when you “only write what’s missing”

Yukkuri Reimu “I’m starting to feel like this is way easier than doing everything in JS.”

Yukkuri Marisa “That’s the goal.”

Minimal JS
HTML leads

Once that instinct clicks,

👉 Instead of “do we even need React?” 👉 You get to “use React only where it earns its keep”

—and you can design that way on purpose.

Chapter 8: Making the UI richer

Introduction

Yukkuri Reimu "We've got CRUD working, and we've seen Turbo Drive, Frames, Streams, and Stimulus. Still, in terms of that 'feels like real product' touch, it can feel a little plain."

Yukkuri Marisa "Yeah. From here on, this chapter is about stacking small UX improvements into an app that feels good to use. And Hotwire is actually really good at building those 'little nicer' experiences."

Yukkuri Reimu "So you don't need an over-the-top SPA to get something that feels really nice."

Yukkuri Marisa "Exactly. In this chapter we'll focus on practical patterns that improve how it feels to use more than how flashy it looks."

This chapter covers four topics:

  • 8.1 Inline editing (edit in place)
  • 8.2 Drag and drop (reordering)
  • 8.3 Loading indicators
  • 8.4 Better form validation

8.1 Inline editing (edit in place)

8.1.1 What inline editing is

Yukkuri Reimu "Inline editing is when you edit right in the list row, right?"

Yukkuri Marisa "Right. Instead of going to a detail screen to edit, it's the UX where you swap the visible row itself for a form."

The idea looks like this:

Normal display:
[ Learn Hotwire ] [Edit]

Press Edit
↓
Turns into a form in place
[ title: Learn Hotwire        ]
[ status: todo                ]
[ Save ] [Cancel]

This pattern works very well with Turbo Frames, Turbo Streams, and Stimulus together.


8.1.2 Start with a display partial

First, check the partial for normal display.

<!-- app/views/tasks/_task_card.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
  <h2><%= task.title %></h2>

  <p>
    <strong>Status:</strong>
    <%= task.status %>
  </p>

  <p>
    <strong>Due:</strong>
    <%= task.due_on %>
  </p>

  <p>
    <%= link_to "Edit", edit_task_path(task), data: { turbo_stream: true } %>
    <%= button_to "Delete", task_path(task), method: :delete %>
  </p>
</section>

Here we treat Edit as a Turbo Stream request.


8.1.3 Add an editing partial

Next, build the edit form to inject into the same spot.

<!-- app/views/tasks/_edit_form.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card task-card--editing">
  <%= form_with(model: task) do |form| %>
    <% if task.errors.any? %>
      <div class="form-errors">
        <ul>
          <% task.errors.full_messages.each do |message| %>
            <li><%= message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div>
      <%= form.label :title %><br>
      <%= form.text_field :title %>
    </div>

    <div>
      <%= form.label :status %><br>
      <%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
    </div>

    <div>
      <%= form.label :due_on %><br>
      <%= form.date_field :due_on %>
    </div>

    <div>
      <%= form.submit "Save" %>
      <%= link_to "Cancel", task_path(task), data: { turbo_stream: true } %>
    </div>
  <% end %>
</section>

8.1.4 Make the edit action Turbo Stream–aware

In edit, replace that row with the edit form.

# app/controllers/tasks_controller.rb
def edit
  respond_to do |format|
    format.turbo_stream
    format.html
  end
end

Create edit.turbo_stream.erb.

<!-- app/views/tasks/edit.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/edit_form", locals: { task: @task } %>

Now when you click Edit, that task row is replaced with the form.

Yukkuri Reimu "This feels really good. No full page navigation, but you still get a clear 'I'm editing now' feeling."

Yukkuri Marisa "Yeah. Editing in place lands hard in business apps."


8.1.5 Return to display after update

After a successful update, switch back to normal display.

# app/controllers/tasks_controller.rb
def update
  respond_to do |format|
    if @task.update(task_params)
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully updated." }
    else
      format.turbo_stream { render :edit, status: :unprocessable_entity }
      format.html { render :edit, status: :unprocessable_entity }
    end
  end
end

update.turbo_stream.erb looks like this:

<!-- app/views/tasks/update.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

On failure, it's clearest if edit.turbo_stream.erb can still be reused as-is.


8.1.6 Cancel back to display

Cancel can also return to normal display via Turbo Stream.

You could add stream support on show, but a light approach is to add show.turbo_stream.erb.

# app/controllers/tasks_controller.rb
def show
  respond_to do |format|
    format.html
    format.turbo_stream
  end
end
<!-- app/views/tasks/show.turbo_stream.erb -->
<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

Now when you press Cancel, the form goes back to normal display.


8.1.7 Auto-focus the first field

With inline editing, it feels better if focus lands on the first input when the form appears. Use Stimulus for that.

// app/javascript/controllers/autofocus_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.focus()
    this.element.select?.()
  }
}

Wire it on the form:

<%= form.text_field :title, data: { controller: "autofocus" } %>

Yukkuri Reimu "Little touches like that really change how it feels."

Yukkuri Marisa "Yeah. Hotwire UI improvements are all about stacking things like this."


8.1.8 Design notes for inline editing

The basic pattern boils down to this:

Display partial
↓ Edit
edit.turbo_stream.erb replaces with the edit form
↓ Save
update.turbo_stream.erb replaces with normal display
↓ Cancel
show.turbo_stream.erb replaces with normal display

So the design is: swap the same DOM region between display and edit versions.


8.2 Drag and drop (reordering)

8.2.1 How to think about reorder UI

Yukkuri Reimu "Next is drag and drop. That sounds pretty JS-heavy."

Yukkuri Marisa "Stimulus does a lot of the work here. Once you include saving on the server, it still pairs fine with Hotwire."

First, add a sort column on Task:

bin/rails generate migration AddPositionToTasks position:integer
bin/rails db:migrate

Seed defaults if you need them:

# db/migrate/xxxxxxxxxxxxxx_add_position_to_tasks.rb
class AddPositionToTasks < ActiveRecord::Migration[7.0]
  def change
    add_column :tasks, :position, :integer
  end
end

On the model, order by that column:

# app/models/task.rb
class Task < ApplicationRecord
  default_scope { order(position: :asc, created_at: :asc) }
end

8.2.2 Add routing

Add a route to update order:

# config/routes.rb
Rails.application.routes.draw do
  resources :tasks do
    patch :reorder, on: :collection
  end

  root "tasks#index"
end

8.2.3 Put data attributes on the list

Attach the reorderable list to a Stimulus controller:

<!-- app/views/tasks/index.html.erb -->
<h1>Tasks</h1>

<ul
  id="tasks"
  data-controller="sortable"
  data-sortable-url-value="<%= reorder_tasks_path %>"
>
  <% @tasks.each do |task| %>
    <li
      id="<%= dom_id(task) %>"
      data-sortable-target="item"
      data-task-id="<%= task.id %>"
      draggable="true"
    >
      <%= render "task_card", task: task %>
    </li>
  <% end %>
</ul>

8.2.4 Handle drag in Stimulus

Here's a minimal version using HTML5 DnD:

// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["item"]
  static values = { url: String }

  connect() {
    this.draggedItem = null

    this.itemTargets.forEach((item) => {
      item.addEventListener("dragstart", this.handleDragStart)
      item.addEventListener("dragover", this.handleDragOver)
      item.addEventListener("drop", this.handleDrop)
      item.addEventListener("dragend", this.handleDragEnd)
    })
  }

  disconnect() {
    this.itemTargets.forEach((item) => {
      item.removeEventListener("dragstart", this.handleDragStart)
      item.removeEventListener("dragover", this.handleDragOver)
      item.removeEventListener("drop", this.handleDrop)
      item.removeEventListener("dragend", this.handleDragEnd)
    })
  }

  handleDragStart = (event) => {
    this.draggedItem = event.currentTarget
    event.dataTransfer.effectAllowed = "move"
  }

  handleDragOver = (event) => {
    event.preventDefault()
    event.dataTransfer.dropEffect = "move"
  }

  handleDrop = (event) => {
    event.preventDefault()

    const dropTarget = event.currentTarget
    if (!this.draggedItem || this.draggedItem === dropTarget) return

    const rect = dropTarget.getBoundingClientRect()
    const offset = event.clientY - rect.top
    const middle = rect.height / 2

    if (offset < middle) {
      dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget)
    } else {
      dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget.nextSibling)
    }

    this.saveOrder()
  }

  handleDragEnd = () => {
    this.draggedItem = null
  }

  saveOrder() {
    const ids = this.itemTargets.map((item) => item.dataset.taskId)

    fetch(this.urlValue, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
        "Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
      },
      body: JSON.stringify({ task_ids: ids })
    })
  }
}

8.2.5 Persist order on the server

Add reorder to the controller:

# app/controllers/tasks_controller.rb
def reorder
  params[:task_ids].each_with_index do |id, index|
    Task.where(id: id).update_all(position: index + 1)
  end

  head :ok
end

That's enough to start.

Yukkuri Reimu "No Turbo Stream response here."

Yukkuri Marisa "For a first step, matching DOM order to DB order matters most. Later you can grow it into broadcasting order changes to other users if you need to."


8.2.6 Polish visuals while dragging

Improve how dragging looks:

/* app/assets/stylesheets/application.css */
[draggable="true"] {
  cursor: grab;
}

[draggable="true"]:active {
  cursor: grabbing;
}

.task-card {
  padding: 12px;
  border: 1px solid #ddd;
  margin-bottom: 8px;
  background: #fff;
}

8.2.7 Production-minded notes

This implementation is fine for learning; in production you also think about:

- What if someone else reorders at the same time
- Whether only authorized users can change order
- Accessibility of drag-and-drop
- Performance with very large lists

In the context of a Hotwire book, understanding this flow is enough for now:

Handle interaction in Stimulus
↓
Save to the server
↓
Optionally reflect elsewhere with Turbo Stream

8.3 Loading indicators

8.3.1 Loading is about reassurance

Yukkuri Reimu "Even when things are fast, no feedback at all can make you anxious."

Yukkuri Marisa "Yeah. Loading UI is less about raw speed and more about whether people feel safe continuing to interact."


8.3.2 Loading during full-page navigation

You can show something simple with Turbo Drive events:

<!-- app/views/layouts/application.html.erb -->
<body>
  <div id="loading-indicator" class="loading-indicator" hidden>
    Loading...
  </div>

  <%= yield %>
</body>
/* app/assets/stylesheets/application.css */
.loading-indicator {
  position: fixed;
  top: 12px;
  right: 12px;
  padding: 8px 12px;
  background: #222;
  color: white;
  border-radius: 8px;
  z-index: 9999;
}
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

const indicator = () => document.getElementById("loading-indicator")

document.addEventListener("turbo:before-visit", () => {
  indicator()?.removeAttribute("hidden")
})

document.addEventListener("turbo:load", () => {
  indicator()?.setAttribute("hidden", true)
})

8.3.3 While a form is submitting

Changing the button label during submit is a classic pattern. Stimulus keeps it tidy:

// app/javascript/controllers/submit_state_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["button", "spinner"]

  start() {
    this.buttonTarget.disabled = true
    this.buttonTarget.value = "Saving..."
    this.spinnerTarget.hidden = false
  }

  finish() {
    this.buttonTarget.disabled = false
    this.buttonTarget.value = "Save"
    this.spinnerTarget.hidden = true
  }
}

On the form:

<%= form_with(model: task,
  data: {
    controller: "submit-state",
    action: "turbo:submit-start->submit-state#start turbo:submit-end->submit-state#finish"
  }) do |form| %>

  <div>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.submit "Save", data: { "submit-state-target": "button" } %>
    <span hidden data-submit-state-target="spinner">Saving...</span>
  </div>
<% end %>

Yukkuri Reimu "This is really nice. It cuts down on the 'did my click do anything?' problem."

Yukkuri Marisa "Yeah. It also helps prevent double submits."


8.3.4 Per-frame loading

If you use Turbo Frames, you often want a loading state inside the frame only.

For example, set up a detail panel like this:

<%= turbo_frame_tag "task_details" do %>
  <div class="placeholder">Select a task.</div>
<% end %>

Add a small Stimulus controller for loading:

// app/javascript/controllers/frame_loading_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.addEventListener("turbo:before-fetch-request", this.show)
    this.element.addEventListener("turbo:frame-load", this.hide)
  }

  disconnect() {
    this.element.removeEventListener("turbo:before-fetch-request", this.show)
    this.element.removeEventListener("turbo:frame-load", this.hide)
  }

  show = () => {
    this.element.classList.add("is-loading")
  }

  hide = () => {
    this.element.classList.remove("is-loading")
  }
}

HTML:

<%= turbo_frame_tag "task_details", data: { controller: "frame-loading" } do %>
  <div class="placeholder">Select a task.</div>
<% end %>

CSS:

#task_details.is-loading {
  opacity: 0.6;
}

8.3.5 Too much loading backfires

Loading UI helps, but overdoing it gets noisy:

- Huge spinners for work that finishes in an instant
- Things flashing all over the screen
- Covering the whole page immediately

A practical split:

- Full page navigation → small fixed indicator
- Form submit → button state change
- Frame fetch → slightly dim just that region

8.4 Better form validation

8.4.1 Rails defaults are already strong

Yukkuri Reimu "When you say better validation, do you mean checking everything in the browser?"

Yukkuri Marisa "No—first use Rails properly. Server-side validation is your last line of defense."

The model looked like this:

# app/models/task.rb
class Task < ApplicationRecord
  STATUSES = %w[todo doing done].freeze

  validates :title, presence: true
  validates :status, presence: true, inclusion: { in: STATUSES }
end

The form already showed errors:

<% if task.errors.any? %>
  <div class="form-errors">
    <ul>
      <% task.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
    </ul>
  </div>
<% end %>

Even that alone matters a lot.


8.4.2 Field-level errors

Beyond a single error box at the top, per-field messages are easier to scan:

<!-- app/views/tasks/_form.html.erb -->
<div class="field">
  <%= form.label :title %><br>
  <%= form.text_field :title, class: ("field-error" if task.errors[:title].any?) %>

  <% task.errors[:title].each do |message| %>
    <div class="error-message"><%= message %></div>
  <% end %>
</div>

CSS:

.field-error {
  border: 1px solid #d33;
  background: #fff7f7;
}

.error-message {
  color: #c00;
  font-size: 0.9rem;
  margin-top: 4px;
}

8.4.3 Show character count while typing

Not validation per se, but very helpful as input guidance:

// app/javascript/controllers/length_counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "output"]
  static values = { max: Number }

  update() {
    const length = this.inputTarget.value.length
    this.outputTarget.textContent = `${length} / ${this.maxValue}`

    this.outputTarget.classList.toggle("over-limit", length > this.maxValue)
  }
}

HTML:

<div
  data-controller="length-counter"
  data-length-counter-max-value="100"
>
  <%= form.label :title %><br>
  <%= form.text_field :title,
      data: {
        "length-counter-target": "input",
        action: "input->length-counter#update"
      } %>

  <div data-length-counter-target="output">0 / 100</div>
</div>

CSS:

.over-limit {
  color: #c00;
  font-weight: bold;
}

8.4.4 Light checks before submit

Keep heavy rules on the server; catching obviously empty required fields in the browser is optional sugar:

// app/javascript/controllers/simple_validation_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["title", "message"]

  validate(event) {
    if (this.titleTarget.value.trim() === "") {
      event.preventDefault()
      this.messageTarget.textContent = "Title is required."
      this.titleTarget.focus()
    } else {
      this.messageTarget.textContent = ""
    }
  }
}

HTML:

<div data-controller="simple-validation">
  <%= form_with(model: task, data: { action: "submit->simple-validation#validate" }) do |form| %>
    <div>
      <%= form.text_field :title, data: { "simple-validation-target": "title" } %>
    </div>

    <div class="error-message" data-simple-validation-target="message"></div>

    <%= form.submit "Save" %>
  <% end %>
</div>

Yukkuri Reimu "But we shouldn't rely on this alone, right?"

Yukkuri Marisa "Right. Client checks are helpers; the server decides for real."


8.4.5 Scroll to errors on long forms

On long forms, people miss errors at the top. Stimulus can nudge:

// app/javascript/controllers/error_focus_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    const errorBox = this.element.querySelector(".form-errors, .error-message")
    if (errorBox) {
      errorBox.scrollIntoView({ behavior: "smooth", block: "center" })
    }
  }
}

Wrap the form:

<div data-controller="error-focus">
  <%= render "form", task: @task %>
</div>

8.4.6 How to think about validation improvements

Summing up the mindset:

Server:
- Owns the real rules
- Decides whether save is allowed

Client:
- Input hints
- Makes problems easier to notice
- Reduces friction

So don't push strict business rules entirely to the client.


Chapter summary

Yukkuri Reimu "This chapter was full of 'things you want in real work.' Nothing flashy, but the app feels a notch better to use."

Yukkuri Marisa "Yeah. Hotwire's strength isn't giant-SPA spectacle—it's honest, incremental UX polish."

Quick recap:

- Inline editing: swap display and edit partials in the same slot
- Turbo Stream replace makes in-place editing feel natural
- Drag-and-drop: Stimulus for interaction, server persists order—that's the baseline
- Loading: differ for full page vs form vs individual frames
- Forms: center server validation; add Stimulus for hints
- With Hotwire, think 'polish HTML-first UI in layers' more than 'rewrite everything in JS'

Exercises

Question 1

When you implement inline editing, what are the two typical partials you swap in with replace?

Question 2

In drag-and-drop reordering, what do you ultimately need to send to the server to persist?

Question 3

What UX benefit do you get from changing a submit button to Saving... while the form posts?

Question 4

How should client-side and server-side validation split responsibilities?

Question 5

What approach can dim a Turbo Frame only while it is loading?


End-of-chapter short column: "Rich UI" is not only huge front-end apps

Yukkuri Reimu "Hearing 'rich UI,' I still picture some giant JS app."

Yukkuri Marisa "A lot of people do. What actually makes software feel good is smaller experiences stacked up."

Everything in this chapter stayed modest:

- Edit in place
- Intuitive reordering
- Clear feedback while submitting
- Errors you notice right away

Put together, that changes how the whole app feels.

Yukkuri Marisa "Hotwire's edge is building just enough richness as a natural extension of Rails."

Chapter 9: Practical design patterns

Introduction

Yukkuri Reimu "By now we've pretty much covered what Hotwire can do. But in real work, it's not enough for things to just work—you also care about not breaking easily and being readable."

Yukkuri Marisa "Exactly. Because Hotwire makes it easy to build, if you're not careful you end up with fat views, bloated controllers, and Turbo Stream responsibilities scattered everywhere."

Yukkuri Reimu "So this chapter is really about not writing Hotwire carelessly."

Yukkuri Marisa "Right. We'll go through design patterns that pay off in practice."

This chapter covers four topics:

  • 9.1 Combining with ViewComponent
  • 9.2 Form Object / Service Object
  • 9.3 Design guidelines for Turbo Streams
  • 9.4 N+1 and performance

9.1 Combining with ViewComponent

9.1.1 Hotwire makes view design unusually important

Yukkuri Reimu "Hotwire is HTML-centric, so views just keep multiplying, don't they."

Yukkuri Marisa "They do. Turbo Frames and Turbo Streams both come down to which HTML you return, so how you organize views maps straight to maintainability."

Even in the app we've built so far, we already have files like this:

app/views/tasks/
  index.html.erb
  show.html.erb
  new.html.erb
  edit.html.erb
  _form.html.erb
  _task_card.html.erb
  _edit_form.html.erb
  create.turbo_stream.erb
  update.turbo_stream.erb
  destroy.turbo_stream.erb

That's still readable, but in real projects, as fields and branches grow, ERB alone gets painful fast.


9.1.2 Why use ViewComponent

With ViewComponent you can pull display units out as Ruby objects.

Say you want to turn one row of the task list into a component.

The idea looks like this:

# app/components/task_card_component.rb
class TaskCardComponent < ViewComponent::Base
  def initialize(task:)
    @task = task
  end

  private

  attr_reader :task
end

Template:

<!-- app/components/task_card_component.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card">
  <h2><%= task.title %></h2>

  <p>
    <strong>Status:</strong>
    <%= task.status %>
  </p>

  <p>
    <strong>Due:</strong>
    <%= task.due_on %>
  </p>

  <p>
    <%= link_to "Edit", edit_task_path(task), data: { turbo_stream: true } %>
    <%= button_to "Delete", task_path(task), method: :delete %>
  </p>
</section>

You could use a helper, but here we simply expose task via a reader.

# app/components/task_card_component.rb
class TaskCardComponent < ViewComponent::Base
  def initialize(task:)
    @task = task
  end

  private

  attr_reader :task
end

From the caller:

<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render(TaskCardComponent.new(task: task)) %>
  <% end %>
</div>

9.1.3 Partial vs component

Yukkuri Reimu "We already have partials—why go out of your way to use a component?"

Yukkuri Marisa "The big difference is that it's easier to keep display logic in Ruby."

Suppose display helpers start to pile up:

# app/components/task_card_component.rb
class TaskCardComponent < ViewComponent::Base
  def initialize(task:)
    @task = task
  end

  def status_label
    case task.status
    when "todo"
      "To Do"
    when "doing"
      "In Progress"
    when "done"
      "Done"
    else
      task.status
    end
  end

  def overdue?
    task.due_on.present? && task.due_on < Date.current && task.status != "done"
  end

  private

  attr_reader :task
end

The template stays clean:

<!-- app/components/task_card_component.html.erb -->
<section id="<%= dom_id(task) %>" class="task-card <%= "task-card--overdue" if overdue? %>">
  <h2><%= task.title %></h2>

  <p>
    <strong>Status:</strong>
    <%= status_label %>
  </p>

  <p>
    <strong>Due:</strong>
    <%= task.due_on %>
  </p>
</section>

Much easier to read than cramming lots of conditionals into ERB.


9.1.4 Turbo Streams and components work well together

You can use components in Turbo Streams too.

<!-- app/views/tasks/update.turbo_stream.erb -->
<%= turbo_stream.replace @task do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

Or, instead of naming a partial, use the block form to render the component directly as the Stream body.

Same for create:

<!-- app/views/tasks/create.turbo_stream.erb -->
<%= turbo_stream.prepend "tasks" do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

Yukkuri Reimu "I see. Rather than worrying about partial names, you think 'this display unit is this component'."

Yukkuri Marisa "Yeah. In Hotwire, which HTML fragment you return matters, so having those fragments organized pays off a lot."


9.1.5 Frames and modals can be components too

You might want to share a modal shell:

# app/components/modal_component.rb
class ModalComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end

  private

  attr_reader :title
end
<!-- app/components/modal_component.html.erb -->
<div class="modal-backdrop">
  <div class="modal-window">
    <h2><%= title %></h2>
    <%= content %>
  </div>
</div>

Caller:

<!-- app/views/tasks/new.html.erb -->
<%= turbo_frame_tag "modal" do %>
  <%= render(ModalComponent.new(title: "New task")) do %>
    <%= render "form", task: @task %>
  <% end %>
<% end %>

That centralizes modal markup and structure.


9.1.6 Don't over-componentize—judgment matters

Still, not everything should be a component.

Examples where partials are enough:

- Simple form snippets
- Tiny display fragments with almost no logic
- Small one-off pieces used only once

Examples that fit components:

- Displays with lots of branches
- When you want display logic as methods
- Reusable visual units
- Fragments returned often via Turbo Streams

Yukkuri Reimu "So it's less 'visual widget' and more 'meaningful display unit' to extract."

Yukkuri Marisa "That instinct matters a lot."


9.2 Form Object / Service Object

9.2.1 Putting everything in the controller gets painful fast

Yukkuri Reimu "CRUD-only controllers weren't that bad, but real apps get way more complex."

Yukkuri Marisa "Right. Once you add things like 'create a Task and a comment at the same time', 'send notifications', 'write audit logs', it gets heavy fast."

A common bloat pattern:

def create
  @task = Task.new(task_params)

  if @task.save
    @task.comments.create!(body: params[:initial_comment]) if params[:initial_comment].present?
    Notification.create!(user: current_user, message: "Task created")
    AuditLog.create!(action: "task_created", record: @task)
    redirect_to @task, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

The controller becomes a dumping ground for business logic.


9.2.2 Form Object: bundle the inputs

Consider a case where the task creation form should also accept initial_comment.

Define a Form Object:

# app/forms/task_form.rb
class TaskForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :title, :string
  attribute :description, :string
  attribute :status, :string
  attribute :due_on, :date
  attribute :initial_comment, :string

  validates :title, presence: true
  validates :status, presence: true

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      task.save!
      task.comments.create!(body: initial_comment) if initial_comment.present?
    end

    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  def task
    @task ||= Task.new(
      title: title,
      description: description,
      status: status,
      due_on: due_on
    )
  end
end

The form uses TaskForm, not Task:

<!-- app/views/tasks/new.html.erb -->
<%= form_with model: @task_form, url: tasks_path do |form| %>
  <div>
    <%= form.label :title %><br>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :description %><br>
    <%= form.text_area :description %>
  </div>

  <div>
    <%= form.label :status %><br>
    <%= form.select :status, Task::STATUSES.map { |s| [s.humanize, s] } %>
  </div>

  <div>
    <%= form.label :due_on %><br>
    <%= form.date_field :due_on %>
  </div>

  <div>
    <%= form.label :initial_comment %><br>
    <%= form.text_area :initial_comment %>
  </div>

  <%= form.submit "Create task" %>
<% end %>

Controller:

# app/controllers/tasks_controller.rb
def new
  @task_form = TaskForm.new(status: "todo")
end

def create
  @task_form = TaskForm.new(task_form_params)

  if @task_form.save
    redirect_to tasks_path, notice: "Task was successfully created."
  else
    render :new, status: :unprocessable_entity
  end
end

private

def task_form_params
  params.require(:task_form).permit(:title, :description, :status, :due_on, :initial_comment)
end

9.2.3 Benefits of Form Object

- Keeps responsibility for form inputs in one place
- Easier to handle inputs spanning multiple models
- Thinner controllers
- Validations scoped to the form

Yukkuri Reimu "The model you're persisting and what the screen accepts don't always match."

Yukkuri Marisa "Once you see that, Form Objects start to feel natural."


9.2.4 Service Object: extract business operations

Now move something like "on Task creation, send notifications and audit logs" into a service:

# app/services/tasks/create_service.rb
module Tasks
  class CreateService
    def initialize(attributes:, actor:)
      @attributes = attributes
      @actor = actor
    end

    attr_reader :attributes, :actor, :task

    def call
      ActiveRecord::Base.transaction do
        @task = Task.create!(attributes)
        Notification.create!(user: actor, message: "Created task ##{task.id}")
        AuditLog.create!(action: "task_created", actor: actor, auditable: task)
      end

      true
    rescue ActiveRecord::RecordInvalid
      false
    end
  end
end

Controller:

# app/controllers/tasks_controller.rb
def create
  service = Tasks::CreateService.new(attributes: task_params, actor: current_user)

  if service.call
    @task = service.task
    redirect_to @task, notice: "Task was successfully created."
  else
    @task = service.task || Task.new(task_params)
    render :new, status: :unprocessable_entity
  end
end

9.2.5 How to choose Form vs Service Object

Rough guidance:

When Form Object fits

- Inputs span multiple models
- Form-specific validations
- Input concepts that aren't "the model itself"

When Service Object fits

- Heavy create/update business logic
- Lots of side effects: notifications, audit, integrations
- You want an explicit transaction boundary

You can combine them:

Form Object accepts input
↓
Service Object runs the save workflow

9.2.6 With Hotwire: don't mix concerns

What matters in Hotwire is not mixing view updates with business logic too tightly.

Bad example:

def create
  @task = Task.new(task_params)
  if @task.save
    Notification.create!(...)
    AuditLog.create!(...)
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to tasks_path }
    end
  else
    ...
  end
end

Cleaner:

def create
  service = Tasks::CreateService.new(attributes: task_params, actor: current_user)

  if service.call
    @task = service.task
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully created." }
    end
  else
    @task = service.task || Task.new(task_params)
    render :new, status: :unprocessable_entity
  end
end

This separates what you're persisting from what you're returning.


9.3 Design guidelines for Turbo Streams

9.3.1 Many Streams make it hard to see what updates where

Yukkuri Reimu "Turbo Streams are handy, but I bet they get messy if you add a lot."

Yukkuri Marisa "That's a huge real-world issue. Streams are too convenient—if you add them mindlessly, you lose track of which update hits which DOM."

A create.turbo_stream.erb like this gets hard to read as it grows:

<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>
<%= turbo_stream.update "flash", partial: "shared/flash", locals: { notice: "Created!" } %>
<%= turbo_stream.replace "sidebar_counts", partial: "tasks/sidebar_counts", locals: { counts: @counts } %>
<%= turbo_stream.update "page_title", "Tasks (#{@counts[:all]})" %>

It works, but the responsibility is too broad.


9.3.2 Define meaningful update units

First, slice DOM updates by meaning.

On a task index page, for example:

- task_form       : form region
- tasks           : list region
- flash           : notice region
- sidebar_counts  : count display

Prefer ids that say what region's job it is, not vague ones like content or main.

<div id="task_form">
  <%= render "form", task: Task.new %>
</div>

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render(TaskCardComponent.new(task: task)) %>
  <% end %>
</div>

<div id="flash">
  <%= render "shared/flash" %>
</div>

9.3.3 Don't put too much logic in Stream templates

Bad example:

<% if @task.priority == "high" %>
  <%= turbo_stream.prepend "tasks", partial: "tasks/high_priority_task_card", locals: { task: @task } %>
<% else %>
  <%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<% end %>

<% if @task.assignee.present? %>
  <%= turbo_stream.update "assignee_badge", @task.assignee.name %>
<% end %>

Branches in Stream templates make update behavior hard to follow.

Improvements:

  • Push display differences into the component / partial
  • Keep Stream templates to where and how you update
<%= turbo_stream.prepend "tasks" do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.update "flash" do %>
  <%= render "shared/flash", notice: "Task created." %>
<% end %>

9.3.4 Don't mix controller-driven and broadcast-driven updates carelessly

A common confusion point:

- prepend in create.turbo_stream.erb
- broadcast_prepend_to in after_create_commit

Using both without a plan causes double updates.

Decide a policy up front.

Pattern A: request/response in the controller; other users via broadcast

Pattern B: lean on broadcast as much as possible

Pattern C: simple screens: controller-only is enough

For learning, this split is clear:

Single-user screen updates:
- controller + *.turbo_stream.erb

Multi-user sync:
- model callback + broadcast_*

9.3.5 Think in UI goals, not operation names

After create, you might technically do several things:

- Add to the list
- Reset the form
- Show a flash

Rather than thinking only in terms of append/replace/update, UI goals age better:

Goals:
- Show the new task
- Make the next entry easy
- Confirm save succeeded

The operations follow:

<%= turbo_stream.prepend "tasks" do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.replace "task_form" do %>
  <%= render "tasks/form", task: Task.new %>
<% end %>

<%= turbo_stream.update "flash" do %>
  <%= render "shared/flash", notice: "Task created." %>
<% end %>

9.3.6 Small helpers to centralize Stream responsibility

If the same update pattern repeats, a helper or tiny object helps:

# app/helpers/tasks_helper.rb
module TasksHelper
  def render_task_card(task)
    render(TaskCardComponent.new(task: task))
  end
end
<%= turbo_stream.replace @task do %>
  <%= render_task_card(@task) %>
<% end %>

Small trick, but Stream templates read much better.


9.3.7 Balance local updates and whole-page consistency

Yukkuri Reimu "If you only ever patch locally, won't counts or list totals drift?"

Yukkuri Marisa "Exactly. In real Hotwire work, you balance the comfort of local updates with consistency across the whole screen."

Adding one task might mean:

  • The list grows
  • Count displays increment
  • Sidebar incomplete counts change

How much you update together is a design choice:

If impact is broad:
- Update related regions together

If impact is narrow:
- Start with the main region only

Trying to sync everything inflates Streams.


9.4 N+1 and performance

9.4.1 Hotwire returns HTML—query efficiency shows up fast

Yukkuri Reimu "Performance talk often centers on JS apps—does it matter for Hotwire too?"

Yukkuri Marisa "It matters a lot. Hotwire assembles HTML on the server, so query count and render work during the view hit you directly."

Classic N+1 example:

<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
  <% @tasks.each do |task| %>
    <section>
      <h2><%= task.title %></h2>
      <p>Assignee: <%= task.assignee.name %></p>
      <p>Comments: <%= task.comments.count %></p>
    </section>
  <% end %>
</div>

If @tasks comes from plain Task.all, you get:

  • A query per task for assignee
  • A query per task to count comments

—that's N+1 territory.


9.4.2 Preload associations with includes

Start here:

# app/controllers/tasks_controller.rb
def index
  @tasks = Task.includes(:assignee, :comments).order(created_at: :desc)
end

That fixes a lot of assignee access.

But comments.count can still trigger extra queries depending on usage—watch how you use it.


9.4.3 Consider counter_cache

If you always show comment counts, a counter cache often fits.

Migration example:

bin/rails generate migration AddCommentsCountToTasks comments_count:integer
# db/migrate/xxxxxxxxxxxxxx_add_comments_count_to_tasks.rb
class AddCommentsCountToTasks < ActiveRecord::Migration[7.0]
  def change
    add_column :tasks, :comments_count, :integer, default: 0, null: false
  end
end

Comment model:

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :task, counter_cache: true
end

View:

<p>Comments: <%= task.comments_count %></p>

No per-row count query for display.


9.4.4 Streams still run queries

Even updating one row via Turbo Stream runs queries if the render lacks data.

After update, say you return:

<%= turbo_stream.replace @task do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

If TaskCardComponent reads task.assignee.name but @task didn't load assignee, you get a query.

You might reload in the controller:

def update
  if @task.update(task_params)
    @task = Task.includes(:assignee).find(@task.id)

    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task updated." }
    end
  else
    ...
  end
end

Yukkuri Reimu "So it's not enough to optimize only the index page."

Yukkuri Marisa "Right. Fragments returned by Streams are real views—you need query awareness there too."


9.4.5 Partial / component render counts matter too

Huge lists cost template rendering, not just queries.

<% @tasks.each do |task| %>
  <%= render(TaskCardComponent.new(task: task)) %>
<% end %>

Clear, but expensive when rows are many.

Directions:

- Cap list size (pagination)
- Avoid heavier display than you need
- Don't load every association for the UI
- Don't re-render the full list on every Stream

9.4.6 Don't abuse "replace everything"

Bad pattern:

<%= turbo_stream.replace "tasks" do %>
  <%= render partial: "tasks/list", locals: { tasks: @tasks } %>
<% end %>

Replacing the full list every time tends to:

  • Increase queries
  • Increase render cost
  • Break scroll position and focus

Prefer local updates when you can:

<%= turbo_stream.prepend "tasks" do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.replace @task do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.remove @task %>

9.4.7 What to measure

Practical checklist:

- SQL statements per request
- N+1s fixable with includes
- Heavy associations loaded in Stream responses
- Overuse of full-list re-render
- Render time as row counts grow

Yukkuri Reimu "Hotwire feels light, but it'll get slow fast without care."

Yukkuri Marisa "True. On the flip side, if you stick to Rails basics, it stays straightforward to make fast."


Chapter summary

Yukkuri Reimu "This chapter felt a lot more 'real world'. Not just how to build with Hotwire, but how to organize it."

Yukkuri Marisa "That was the point. Summing up:"

- Hotwire cares a lot about HTML fragment design, so ViewComponent fits well
- When display logic grows, consider components—not only partials
- Form Objects fit bundling form input responsibility
- Service Objects fit extracting create/update business logic
- Turbo Streams stay maintainable when update targets have clear jobs and you think local updates
- Don't mix controller-driven and broadcast-driven updates without a clear policy
- N+1 and render cost still matter; includes and counter_cache help
- Prefer prepend / replace / remove over constantly replacing the whole list

Exercises

Question 1

Name two or more cases where ViewComponent fits better than a partial.

Question 2

What responsibilities are Form Objects vs Service Objects suited for?

Question 3

Why avoid vague ids like content or main for Turbo Stream update targets?

Question 4

What performance issues should you watch for in this code?

<% @tasks.each do |task| %>
  <p>Assignee: <%= task.assignee.name %></p>
  <p>Comments: <%= task.comments.count %></p>
<% end %>

Question 5

Why prefer prepend or row-level replace over turbo_stream.replace "tasks" that re-renders the whole list every time?


End-of-chapter mini column: Hotwire is easy to build—so design gaps show

Yukkuri Reimu "Hotwire's charm is that not much code gets you something working."

Yukkuri Marisa "That's a real strength. But 'it works, so ship it' stacks up unreadable code later."

Gaps like these look small at first but grow:

- Whether partials are organized
- Whether Stream responsibilities are clear
- Whether input handling is separated from business logic
- Whether queries are tuned for display

Without heavyweight SPA architecture, Hotwire makes Rails-style design skill show up directly in quality.

Yukkuri Marisa "So Hotwire will run if you hack it—but it's strong when you build it carefully."

Chapter 10: Testing strategy

Introduction

Yukkuri Reimu "By this point the Hotwire app is getting pretty practical. But then you start wondering the opposite: 'Is it actually not going to break?'"

Yukkuri Marisa "That's what Chapter 10 is for. Hotwire is HTML-centric so it looks easy to test at first glance, but once Turbo and Stimulus are in the mix, if you don't sort out what to test how far with what, things get confusing fast."

Yukkuri Reimu "So it's not as simple as 'just run everything as system tests.'"

Yukkuri Marisa "Right. This chapter pulls together verifying user actions with Capybara, Turbo-specific behavior, how to look at small Stimulus behaviors, and ideas for running tests stably on CI."

This chapter covers four topics:

  • 10.1 System tests (Capybara)
  • 10.2 Turbo-oriented tests
  • 10.3 Testing Stimulus
  • 10.4 Running on CI

10.1 System tests (Capybara)

10.1.1 System tests matter especially in Hotwire apps

Yukkuri Reimu "There are model tests and request tests too, but what's most important with Hotwire?"

Yukkuri Marisa "The clearest win is system tests, first of all. The reason is simple: with Hotwire, the value is what changes on screen from the user's point of view."

For example, system tests are a strong fit for things like:

- After creating a task, it shows up in the list
- Inline edit swaps to a form
- After delete, the row disappears
- A modal opens
- Flash messages appear

In other words, what matters is verifying how it finally looks in the browser.


10.1.2 Start with a minimal system test

In Rails system tests you can describe browser actions with Capybara. Start from the basics of creating a task.

# test/system/tasks_test.rb
require "application_system_test_case"

class TasksTest < ApplicationSystemTestCase
  test "creating a task" do
    visit tasks_path

    fill_in "Title", with: "Learn Hotwire testing"
    select "To Do", from: "Status"
    fill_in "Description", with: "Write system tests"
    fill_in "Due on", with: Date.current + 3

    click_on "Create task"

    assert_text "Learn Hotwire testing"
  end
end

Yukkuri Reimu "So you basically write what the user does."

Yukkuri Marisa "Yeah. With Hotwire, this kind of action-based read is often easier to follow."


10.1.3 Using fixtures

If you're testing updates or deletes, existing data helps.

# test/fixtures/tasks.yml
one:
  title: Learn Turbo
  description: Basic CRUD with Hotwire
  status: todo
  due_on: 2026-04-10
  position: 1

two:
  title: Learn Stimulus
  description: Add small UI interactions
  status: doing
  due_on: 2026-04-12
  position: 2

With that in place, you can write tests like this:

# test/system/tasks_test.rb
require "application_system_test_case"

class TasksTest < ApplicationSystemTestCase
  setup do
    @task = tasks(:one)
  end

  test "updating a task" do
    visit tasks_path

    click_on "Edit", match: :first
    fill_in "Title", with: "Learn Turbo deeply"
    click_on "Save"

    assert_text "Learn Turbo deeply"
  end
end

10.1.4 Checking element presence carefully with Capybara

In Hotwire apps, beyond bare assert_text, looking at which elements changed how makes tests harder to break.

For example, if the task list has id="tasks", you can scope:

test "creating a task adds it to the tasks list" do
  visit tasks_path

  fill_in "Title", with: "Task from system test"
  select "To Do", from: "Status"
  click_on "Create task"

  within "#tasks" do
    assert_text "Task from system test"
  end
end

That way you're less likely to get a false pass when the same text appears somewhere else by coincidence.


10.1.5 Add explicit attributes for hard-to-find elements

Yukkuri Reimu "But with Hotwire, elements updated in fragments, modals, stuff like that—don't you sometimes struggle to target them in tests?"

Yukkuri Marisa "Yeah. In practice, being a bit deliberate about test-friendly HTML saves a lot of pain."

For example, you might add a data-testid-style attribute on edit links:

<%= link_to "Edit",
            edit_task_path(task),
            data: { turbo_stream: true, testid: "edit-task-#{task.id}" } %>

Capybara can grab them with CSS selectors.

find('[data-testid="edit-task-1"]').click

Or even a clear id is often enough.

<section id="<%= dom_id(task) %>">
  ...
</section>
within "#task_#{@task.id}" do
  assert_text @task.title
end

10.1.6 Use system tests with JavaScript enabled

To exercise Turbo and Stimulus for real, you need a JS-capable driver. In Rails system tests you set driven_by:

# test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end

That lets you confirm Turbo partial updates and Stimulus behavior in the browser.

Yukkuri Reimu "So this is where a headless browser actually matters."

Yukkuri Marisa "Right. If you want to try Hotwire from a real user's perspective, this part is pretty important."


10.1.7 Don't cram everything into system tests

System tests are powerful, but if you throw everything in they get heavy.

A good approach is to focus on main flows that carry user value:

- Can create a task
- Can edit a task in place
- Can delete a task
- Can create from a modal
- Reorder results persist

Conversely, pushing every tiny internal branch into system tests tends to make the suite heavy.


10.2 Turbo-oriented tests

10.2.1 With Turbo you get more "success without a full navigation"

Yukkuri Reimu "With Turbo, a lot of the time the UI changes but you never fully reload, right?"

Yukkuri Marisa "Right. So in Turbo-oriented tests, the mindset of what the DOM finally looks like matters a lot."

For example, if a Turbo Stream appends to the list, you assert like this:

test "creating a task updates the list with turbo stream" do
  visit tasks_path

  fill_in "Title", with: "Turbo Stream task"
  select "To Do", from: "Status"
  click_on "Create task"

  within "#tasks" do
    assert_text "Turbo Stream task"
  end
end

Here the list actually updating is more essential than the redirect URL.


10.2.2 Testing inline editing

Inline editing is a classic Turbo Stream pattern. You exercise the flow: view → form swap → save → back to view.

test "editing a task in place" do
  task = tasks(:one)

  visit tasks_path

  within "#task_#{task.id}" do
    click_on "Edit"
  end

  within "#task_#{task.id}" do
    assert_selector "form"
    fill_in "Title", with: "Edited in place"
    click_on "Save"
  end

  within "#task_#{task.id}" do
    assert_text "Edited in place"
    assert_no_selector "form"
  end
end

Yukkuri Reimu "assert_no_selector 'form' is nice—you can tell you're back to the display state."

Yukkuri Marisa "Yeah. With Hotwire, spelling out the state after the swap is important."


10.2.3 Delete tests: confirm it disappears

Deletes are simple, but you can still assert Turbo Stream remove behavior.

test "destroying a task removes it from the list" do
  task = tasks(:one)

  visit tasks_path

  within "#task_#{task.id}" do
    click_on "Delete"
  end

  assert_no_selector "#task_#{task.id}"
end

If needed, you can also check count changes.

test "destroying a task reduces visible task count" do
  visit tasks_path

  initial_count = all("#tasks .task-card").count

  click_on "Delete", match: :first

  assert_equal initial_count - 1, all("#tasks .task-card").count
end

10.2.4 Testing Turbo Frames

With Frames, assert that only inside the frame updates.

For example, with a detail frame:

test "showing a task updates the task details frame" do
  task = tasks(:one)

  visit tasks_path

  within "#task_#{task.id}" do
    click_on task.title
  end

  within "turbo-frame#task_details" do
    assert_text task.title
    assert_text task.status
  end
end

The important part is looking at the frame region, not the whole page.


10.2.5 Inspecting Turbo Stream responses in request tests

Beyond system tests, if you want to verify response shape, request tests help too:

# test/controllers/tasks_controller_test.rb
require "test_helper"

class TasksControllerTest < ActionDispatch::IntegrationTest
  test "create responds with turbo stream" do
    assert_difference("Task.count", 1) do
      post tasks_path,
           params: {
             task: {
               title: "Turbo response test",
               status: "todo"
             }
           },
           headers: { "Accept" => "text/vnd.turbo-stream.html" }
    end

    assert_response :success
    assert_equal "text/vnd.turbo-stream.html; charset=utf-8", response.media_type + "; charset=utf-8"
    assert_includes response.body, %(<turbo-stream)
  end
end

It doesn't replace browser behavior, but it's handy to guarantee this action should return turbo-stream.


10.2.6 Even when it feels async, think in terms of waiting

In system tests that include Turbo or Stimulus, DOM updates can lag slightly. Capybara will wait—what matters is not asserting too eagerly.

Bad:

click_on "Create task"
assert page.html.include?("New task")

Better:

click_on "Create task"
assert_text "New task"

Or:

assert_selector "#tasks", text: "New task"

Leaning on Capybara's waiting tends to be more stable.


10.2.7 Multiple updates: prefer important outcomes over checking everything

One Turbo Stream can update several places at once:

- Append to list
- Reset form to empty
- Show flash

You can assert all of that in one test, but it often gets brittle. In practice, center assertions on the main outcomes.

test "creating a task shows it in the list" do
  ...
end

test "creating a task resets the form" do
  ...
end

Keeping one clear responsibility per test reads better.


10.3 Testing Stimulus

10.3.1 You don't have to E2E everything in the browser

Yukkuri Reimu "Stimulus tests sound hard. Running the full browser for tiny JS every time feels heavy."

Yukkuri Marisa "Mindset matters there. Stimulus splits small responsibilities, so you don't need heavy E2E for every piece."

You can split it roughly like this:

- Important user actions → system test
- Small DOM tweaks → JS test or a narrow system test

10.3.2 Cases where system test is enough first

For example, a character counter is fine with a system test:

test "title length counter updates while typing" do
  visit new_task_path

  fill_in "Title", with: "Hotwire"

  assert_text "7 / 100"
end

Or a toggle UI:

test "details can be toggled" do
  visit tasks_path

  click_on "Toggle details"

  assert_text "Hidden content"
end

Meaningful Stimulus behavior from the user's perspective often starts with system tests.


10.3.3 When to lean toward isolated JS tests

On the other hand, when a Stimulus controller gets a bit complex, you may want JS tests that stand up the DOM and drive the controller.

For example, this controller:

// app/javascript/controllers/length_counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "output"]
  static values = { max: Number }

  update() {
    const length = this.inputTarget.value.length
    this.outputTarget.textContent = `${length} / ${this.maxValue}`
    this.outputTarget.classList.toggle("over-limit", length > this.maxValue)
  }
}

The testing idea looks like this:

1. Build a DOM fragment
2. Connect the controller
3. Change the input value
4. Call update()
5. Check output textContent / classes

Pseudocode-style sketch:

import { Application } from "@hotwired/stimulus"
import LengthCounterController from "controllers/length_counter_controller"

describe("LengthCounterController", () => {
  let application
  let element

  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="length-counter" data-length-counter-max-value="5">
        <input data-length-counter-target="input">
        <div data-length-counter-target="output"></div>
      </div>
    `

    application = Application.start()
    application.register("length-counter", LengthCounterController)
    element = document.querySelector('[data-controller="length-counter"]')
  })

  afterEach(() => {
    application.stop()
    document.body.innerHTML = ""
  })

  it("updates the output text", () => {
    const input = element.querySelector('[data-length-counter-target="input"]')
    const output = element.querySelector('[data-length-counter-target="output"]')

    input.value = "hello"
    input.dispatchEvent(new Event("input"))

    expect(output.textContent).toBe("5 / 5")
  })
})

Yukkuri Reimu "I see. Stimulus hugs the DOM, so tests think in DOM terms too."

Yukkuri Marisa "Right. It's a slightly different feel from React-style component tests."


10.3.4 Mind connect / disconnect

In Stimulus, connect() and disconnect() matter. When Turbo swaps fragments, controllers attach and detach.

For example:

// app/javascript/controllers/highlight_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.element.classList.add("highlight")
  }

  disconnect() {
    this.element.classList.remove("highlight")
  }
}

For this style of controller, it's worth thinking about initializing correctly on connect and cleaning up on disconnect.


10.3.5 Controllers using addEventListener need extra care

If you register raw DOM events by hand in Stimulus and don't remove them in disconnect(), tests and production can both get flaky.

Bad:

connect() {
  window.addEventListener("resize", this.handleResize)
}

Better:

connect() {
  window.addEventListener("resize", this.handleResize)
}

disconnect() {
  window.removeEventListener("resize", this.handleResize)
}

For controllers like this, leaning on more isolated JS tests than system tests sometimes feels safer.


10.3.6 Stimulus testing strategy in summary

A practical breakdown:

Simple UI helpers:
- Often enough with system tests

Somewhat complex controllers:
- Consider DOM-based JS tests

Controllers with heavy side effects:
- Mind connect / disconnect / event teardown

So with Stimulus, instead of "test everything heavily," pick the approach by how big the responsibility is.


10.4 Running on CI

10.4.1 Passing locally isn't enough

Yukkuri Reimu "Even if you write tests, only running them locally still feels risky."

Yukkuri Marisa "Exactly. Especially with Hotwire stacks, whether JS browser tests run stably on CI matters a lot."

Roughly, you want CI to cover:

- Model tests
- Request / controller tests
- System tests
- JS tests when needed

10.4.2 A minimal CI policy

For readers of the book, this policy is realistic to start with:

1. Set up Ruby
2. Provision the database
3. Wire up assets / JS
4. Run tests
5. Run system tests with a headless browser

For example, on GitHub Actions, the shape looks like this:

# .github/workflows/ci.yml
name: CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: password
        ports:
          - 5432:5432

    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:password@localhost:5432/app_test

    steps:
      - uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

      - name: Set up Chrome
        uses: browser-actions/setup-chrome@v1

      - name: Install dependencies
        run: |
          bundle install
          bin/rails db:prepare

      - name: Run tests
        run: |
          bin/rails test
          bin/rails test:system

This is only a minimal sketch, but running system tests on CI too is the important part.


10.4.3 When system tests flake on CI: what to review

Hotwire CI often struggles with flaky tests.

Common causes:

- Asserting immediately
- Ambiguous selectors
- Not accounting for Turbo settling
- Not scoping to modals or frames
- Test data interfering across cases

Improvement directions:

- Use assert_text / assert_selector so waiting helps
- Narrow scope with within
- Make ids or data-testid explicit
- One responsibility per test

10.4.4 Use screenshots

When system tests fail on CI, HTML logs alone can hide the picture. Turning on screenshot capture helps.

Rails system tests make failure screenshots easy to use. Keep the default behavior, and if needed, think about where they land:

# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]

  # Use Rails' built-in failure screenshots
end

Collecting them as CI artifacts is convenient:

- name: Upload screenshots
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: system-test-screenshots
    path: tmp/screenshots

Yukkuri Reimu "For UI-ish tests, having what it looked like when it failed really helps."

Yukkuri Marisa "Yeah. Turbo issues are often about what actually showed on screen."


10.4.5 Tier your tests

To keep CI fast, tiering tests works:

On each push:
- Models
- Requests
- Lighter system tests

Nightly or on main:
- Full system suite
- Full JS suite

Depends on project size, but whether to run everything every time or split by use case is an ops design choice.


10.4.6 Practical CI feel for Hotwire

What matters in Hotwire CI:

- Breakage doesn't only happen server-side—screen behavior breaks too
- Turbo/Stimulus changes what you see, so system tests pay off
- But sloppy system tests make CI unstable

So:

- Curate main flows
- Stabilize selectors
- Use asserts that include waiting
- Keep ways to investigate failures

Chapter summary

Yukkuri Reimu "Testing strategy isn't just 'write a ton'—it's splitting what you guarantee where."

Yukkuri Marisa "Exactly. That split pays off especially in Hotwire apps."

Key points from this chapter:

- In Hotwire apps, system tests that check behavior from the user's perspective are especially valuable
- With Capybara, scoped asserts that check DOM changes carefully tend to be more stable
- Turbo-oriented tests: final DOM state matters more than URL navigation
- Turbo Frames: check inside the frame; Turbo Streams: check how updated targets change
- Stimulus: system tests for important behavior; consider DOM-based JS tests when complex
- connect / disconnect and event cleanup are Stimulus-specific gotchas
- On CI, run system tests with a headless browser for stable execution
- For flaky tests: waiting-friendly asserts, clear selectors, screenshot artifacts

Exercises

Question 1

Why are system tests especially valuable in Hotwire apps?

Question 2

When testing a list update via Turbo Stream, what is the benefit of scoping with something like within "#tasks" instead of only assert_text?

Question 3

In an inline-edit test, what does adding assert_no_selector "form" after save signify?

Question 4

Why should you pay attention to connect() and disconnect() on Stimulus controllers?

Question 5

Name at least two things to revisit when system tests are unstable on CI.


End-of-chapter mini-column: Don't shy away from testing whether the screen actually works

Yukkuri Reimu "With tests, it's easy to stare at models and services—the 'inside' stuff."

Yukkuri Marisa "That matters too, but with Hotwire, having the courage to look at the outside is pretty important."

Hotwire's value ultimately shows up in things like:

- Edit right there
- Updates show up immediately
- Modals open naturally
- Errors read well

So how the screen behaves is the essence. System tests aren't "avoid because they're heavy"—they're a good fit as tests that guard valuable behavior.

Yukkuri Marisa "Hotwire is tech for making the UX feel good, so you use tests to protect that feel."

Chapter 11: Deployment and operations

Introduction

Yukkuri Reimu “By now the Hotwire app has really taken shape. But ‘it runs locally’ and ‘it stays stable in production’ are different stories, aren’t they.”

Yukkuri Marisa “Exactly. Especially with Hotwire apps—you still have ordinary Rails ops, plus Turbo Streams and the real-time side of Action Cable, so you have to think seriously about topology, performance, and monitoring.” ([Ruby on Rails Guides][2])

Yukkuri Reimu “So this chapter is about ‘how do you actually run Hotwire well in production.’”

Yukkuri Marisa “Yeah. Compared to flashy code, whether this foundation is solid makes a huge difference to how reassuring the work feels in practice.”

This chapter covers the following four topics.

  • 11.1 Production topology
  • 11.2 Scaling Action Cable
  • 11.3 Performance tuning
  • 11.4 Logging and monitoring

11.1 Production topology

11.1.1 Start with the big picture

Yukkuri Reimu “Does production need something special because it’s Hotwire?”

Yukkuri Marisa “It’s less ‘special’ and more: Rails core + DB + asset delivery + how you support realtime connections—that’s what matters.”

The minimal mental model for production looks like this.

[ User Browser ]
      |
      v
[ Reverse Proxy / Load Balancer ]
      |
      +----------------------+
      |                      |
      v                      v
[ Rails App Server ]    [ Action Cable ]
      |
      v
[ Database ]

But you don’t have to split things from day one. At small scale running Rails and Action Cable together is perfectly fine. The official Action Cable guide assumes you can host Cable in the same process as the app or in separate processes. ([Ruby on Rails Guides][2])


11.1.2 Small deployments can stay simple

For your first production layout, straightforward thinking is fine.

- Rails app (Puma)
- PostgreSQL
- Redis or another Cable backend
- Nginx / LB / ingress, etc.
- Object storage / CDN as needed

The production tuning guide still centers on tuning workers, threads, and memory around Puma while measuring. ([Ruby on Rails Guides][3])

For example, config/puma.rb might look like this.

# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
threads threads_count, threads_count

workers ENV.fetch("WEB_CONCURRENCY", 2)

preload_app!

port ENV.fetch("PORT", 3000)

plugin :tmp_restart

Yukkuri Reimu “So the point is not to make it complicated from the start.”

Yukkuri Marisa “Right. You don’t need a fully split architecture on day one just because it’s Hotwire.”


11.1.3 With Rails 8, Kamal is easy to keep in mind

Recent Rails has Kamal 2 in Rails 8 as a more standard deploy story—that’s a notable shift. The official blog positions Rails 8 as “No PaaS Required” and makes Linux server deploys much more straightforward. ([Rails][1])

In a book like this, it’s natural to show something like:

bin/kamal setup
bin/kamal deploy

Still, for this book the focus belongs on production design for Hotwire, not a deep dive into Kamal itself.


11.1.4 Environment variables to care about in production

At minimum, organize env vars along these lines.

RAILS_ENV=production
RAILS_LOG_TO_STDOUT=1
RAILS_SERVE_STATIC_FILES=1
DATABASE_URL=postgres://...
REDIS_URL=redis://...
RAILS_MASTER_KEY=...
RAILS_MAX_THREADS=5
WEB_CONCURRENCY=2

If you use Turbo Streams / Action Cable with Hotwire, making WebSocket-related backends explicit in production is important. ([Ruby on Rails Guides][2])


11.1.5 Action Cable connection settings

For example, config/cable.yml might look like this.

# config/cable.yml
production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") %>
  channel_prefix: myapp_production

The Action Cable guide describes using the Redis adapter in production to back pub/sub as the baseline. ([Ruby on Rails Guides][2])

Routing is usually like this.

# config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => "/cable"

  resources :tasks
  root "tasks#index"
end

11.1.6 Assets and caching are part of production too

Even with Hotwire, you ultimately serve HTML, CSS, and JS. Rails 8 continues to evolve the surrounding defaults—Propshaft, Solid Cache, and so on. ([Ruby on Rails Guides][4])

This level of awareness is enough to start.

- Serve CSS/JS with fingerprints
- Use a reverse proxy / CDN as needed
- Consider fragment caching
- Think separately about Cable connection load vs. web response time

11.1.7 The first rule of production design

Yukkuri Reimu “So where do we start, in the end?”

Yukkuri Marisa “Start here.”

1. Are web requests fast and stable?
2. Can Cable connections stay up?
3. Are DB and cache not overloaded?
4. Can you see what’s wrong when something fails?

The mindset is operable architecture, not just shipping features.


11.2 Scaling Action Cable

11.2.1 Action Cable assumes long-lived connections

Yukkuri Reimu “How is Action Cable really different from ordinary HTTP?”

Yukkuri Marisa “The big one is keeping connections open. HTTP is request–response–done; WebSockets assume the connection stays up.” ([Ruby on Rails Guides][2])

So in production you care about more than raw request throughput:

- Concurrent connections
- Connection duration
- Broadcast frequency
- Memory per server

11.2.2 Colocation is fine at first; split when growth demands it

At small scale this still works.

Puma
├─ Web request
└─ Action Cable

As connections grow, Web and Cable behave differently—you’ll want to separate them.

[ LB ]
  ├─ Web app servers
  └─ Cable servers

The Action Cable guide covers standalone vs. co-located setups; separation becomes natural at scale. ([Ruby on Rails Guides][2])


11.2.3 Redis and backends matter as relays

When Action Cable spans multiple processes and servers, every broadcast—no matter which server originated it—must reach all subscribers.

Redis-style pub/sub backends handle that relay.

Rails process A  --\
Rails process B  --- Redis pub/sub ---> Cable subscribers
Rails process C  --/

The Redis settings in config/cable.yml exist for exactly this. ([Ruby on Rails Guides][2])


11.2.4 Don’t broadcast coarsely

Yukkuri Reimu “Isn’t it easiest to blast everything to everyone?”

Yukkuri Marisa “That’s easy at first, but it hurts at scale.”

Bad example:

<%= turbo_stream_from "global_tasks" %>

Everyone receives every task update.

Better example:

<%= turbo_stream_from [@project, "tasks"] %>

Model side:

after_create_commit -> {
  broadcast_prepend_to [project, "tasks"],
    target: "tasks",
    partial: "tasks/task_card",
    locals: { task: self }
}

Now only the people who need the update subscribe to it.


11.2.5 Broadcast frequency has a cost too

This kind of implementation is risky:

after_update_commit -> { broadcast_replace_to "tasks" }

If Task updates often in small increments, replace storms hammer both UI and servers.

Directions to rethink:

- Do you truly need realtime sync?
- Which attribute changes should trigger a broadcast?
- Is row-level enough, or do you need a full list refresh?
- Can you reduce bursty successive updates?

11.2.6 Don’t wire Cable everywhere

Hotwire is strong at realtime, but not every screen needs Cable.

Good fits:

- Collaborative-feeling lists
- Comments and notifications
- Dashboards where status changes matter most

Screens where forcing Cable in isn’t worth it:

- Mostly solo admin UIs
- Low-churn settings
- Detail pages where perfect sync adds little

Yukkuri Reimu “Realtime is handy, but the goal comes first.”

Yukkuri Marisa “Right. Cable is for because we need it, not because we can.”


11.2.7 What scaling Action Cable looks like

Typical signals to watch:

- Concurrent connections
- Memory per process
- Broadcasts per second
- Connection failure rate
- Reconnection frequency

Measuring HTTP response time alone often hides Cable-side pain.


11.3 Performance tuning

11.3.1 Nail ordinary Rails performance first

Yukkuri Reimu “Where do you actually look for Hotwire performance?”

Yukkuri Marisa “First things first: the Rails app itself should be fast. Hotwire returns HTML—DB efficiency, templates, and caching all matter directly.” ([Ruby on Rails Guides][5])

Early wins are mundane:

- Eliminate N+1s
- Avoid full-list redraws when unnecessary
- Cache cacheable fragments
- Tune Puma while measuring

11.3.2 Don’t add Streams on top of N+1 left behind

Controller example:

def index
  @tasks = Task.includes(:assignee, :comments).order(created_at: :desc)
end

View example:

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render(TaskCardComponent.new(task: task)) %>
  <% end %>
</div>

Even if you replace @task over Streams, re-loading associations inside the component each time defeats the purpose.

def update
  if @task.update(task_params)
    @task = Task.includes(:assignee).find(@task.id)

    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to tasks_path }
    end
  else
    ...
  end
end

11.3.3 Consider fragment caching

The caching guide treats fragment caching as central. ([Ruby on Rails Guides][5])

You can cache each row component in a list, for instance.

<!-- app/components/task_card_component.html.erb -->
<% cache task do %>
  <section id="<%= dom_id(task) %>" class="task-card">
    <h2><%= task.title %></h2>
    <p><strong>Status:</strong> <%= task.status %></p>
    <p><strong>Due:</strong> <%= task.due_on %></p>
  </section>
<% end %>

Watch out for:

- Don’t share-cache carelessly where per-user views differ
- Where broadcasts swap content often, align cache strategy
- Size cache keys appropriately

11.3.4 Avoid replacing the entire list

Bad example:

<%= turbo_stream.replace "tasks" do %>
  <%= render partial: "tasks/list", locals: { tasks: @tasks } %>
<% end %>

Problems:

  • Easier N+1s
  • Heavy render cost
  • Scroll position jumps
  • Lots of DOM churn

Prefer localized updates:

<%= turbo_stream.prepend "tasks" do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.replace @task do %>
  <%= render(TaskCardComponent.new(task: @task)) %>
<% end %>

<%= turbo_stream.remove @task %>

11.3.5 Decide Puma concurrency by measuring

The production tuning guide adjusts Puma threads and workers based on CPU, memory, and I/O behavior. ([Ruby on Rails Guides][3])

For example:

# config/puma.rb
threads ENV.fetch("RAILS_MAX_THREADS", 5), ENV.fetch("RAILS_MAX_THREADS", 5)
workers ENV.fetch("WEB_CONCURRENCY", 2)
preload_app!

Treating fixed numbers as doctrine is risky.

- Threads help when you wait on DB a lot
- Workers matter more under CPU-heavy work
- If memory is tight, don’t crank workers blindly

11.3.6 Mind dev vs. production gaps

Yukkuri Reimu “Fast locally, slow in production—that’s familiar.”

Yukkuri Marisa “Totally common. With Hotwire you return HTML fragments often, so template, DB, and WebSocket differences bite harder.”

Typical divergence:

- Database latency
- Reverse proxy / CDN presence
- Distance/latency to Redis
- WebSocket connection count
- Whether caching is enabled

bin/rails dev:cache helps in development, but it won’t mirror production exactly—validate caching with production-ish assumptions. ([Ruby on Rails Guides][6])


11.3.7 Performance tuning order

A sane default order:

1. Fix N+1s
2. Reduce full-list redraws
3. Introduce fragment caching
4. Tune Puma / infrastructure
5. Refine broadcast granularity

Usually trimming waste in the app beats rushing fancy infra tuning.


11.4 Logging and monitoring

11.4.1 The worst ops pain is not seeing

Yukkuri Reimu “After deploy, what matters most when things break?”

Yukkuri Marisa “The worst pain is not knowing what’s happening—that’s why logging and monitoring are huge even if they’re not flashy.” ([Ruby on Rails Guides][7])


11.4.2 Make Rails logs traceable by request id first

Rails supports log tags—the configuration guide mentions config.log_tags. ([Ruby on Rails Guides][8])

# config/environments/production.rb
config.log_tags = [ :request_id ]

You can also add user context or subdomain if needed:

config.log_tags = [
  :request_id,
  ->(req) { "ip=#{req.remote_ip}" }
]

This makes it easier to follow logs tied to one request.


11.4.3 You want Turbo Streams / Cable visibility on its own

Beyond ordinary HTTP logs, also watch:

- /cable connection success vs. failure
- Broadcast frequency
- Unexpected floods of stream updates
- Specific pages with heavy responses only

Action Cable logs are worth viewing separately from web requests—“the page loads but realtime updates stopped” failures exist. ([Ruby on Rails Guides][2])


11.4.4 Use Rails’s error reporter

Rails includes an error reporter. The guides describe sending exceptions to external services via this pathway. ([Ruby on Rails Guides][9])

Roughly:

Rails.error.report(exception, handled: true, context: { feature: "task_realtime" })

Sentry and friends are common, but for the book know that Rails exposes a standard entry point. ([Ruby on Rails Guides][9])


11.4.5 You can add custom instrumentation

Active Support instrumentation lets you measure app-specific events. ([Ruby on Rails Guides][10])

To time a heavy reorder:

# app/controllers/tasks_controller.rb
def reorder
  ActiveSupport::Notifications.instrument("tasks.reorder") do
    params[:task_ids].each_with_index do |id, index|
      Task.where(id: id).update_all(position: index + 1)
    end
  end

  head :ok
end

Subscriber sketch:

ActiveSupport::Notifications.subscribe("tasks.reorder") do |name, start, finish, id, payload|
  duration_ms = ((finish - start) * 1000).round(1)
  Rails.logger.info("[instrumentation] #{name} took #{duration_ms}ms")
end

Yukkuri Reimu “Measuring slow spots yourself is nice.”

Yukkuri Marisa “Yeah—ops is ultimately visibility.”


11.4.6 What monitoring should cover at minimum

Baseline targets:

- HTTP 5xx rate
- Response time
- Database connection errors
- WebSocket connection failure rate
- Memory usage
- CPU utilization
- Queue/job backlog

Hotwire-focused extras:

- Cable connection count
- Broadcast volume
- Major Turbo Stream update failures
- Signs when UI updates silently stop

11.4.7 Make production logs usable

Messy prod logs quickly become unreadable.

Suggestions:

- Attach request_id
- Emit important events in a quasi-structured way
- Send exceptions via the error reporter
- Avoid chatty logs for boring success paths

Example reorder logging:

Rails.logger.info(
  event: "tasks.reorder",
  actor_id: current_user.id,
  task_ids: params[:task_ids]
)

Exact shape depends on the logger—but search-friendly fields pay off later.


11.4.8 Have an incident playbook order

Yukkuri Reimu “If someone says ‘realtime updates stopped,’ where do you start?”

Yukkuri Marisa “This order helps.”

1. Is HTTP itself healthy?
2. Do /cable connections succeed?
3. Are broadcasts actually firing?
4. Is Redis / the backend healthy?
5. On the page, is turbo_stream_from wired correctly?
6. Stream target IDs not mismatched?

In short: isolate app vs. Cable vs. backend vs. DOM.


Chapter summary

Yukkuri Reimu “This chapter smelled like real operations—moving from ‘it runs’ to ‘we can run it.’”

Yukkuri Marisa “That was the intent. The takeaways boil down to this.”

- For Hotwire production, clarify roles: Rails / DB / Cable / proxy first
- At small scale, co-locating Action Cable is fine; split when connections grow
- Pair Action Cable with Redis-like pub/sub to fan out across processes
- Don’t spam global broadcasts—narrow subscriptions
- For performance: start with N+1, whole-list redraws, caching
- Don’t worship fixed Puma numbers—measure and adjust
- Use request_id and enough Cable telemetry in logs
- Rails error reporter + instrumentation improve troubleshooting and observability

Exercises

Question 1

For a small Hotwire app, why is it acceptable not to fully separate the web servers and Action Cable servers from day one?

Question 2

When spreading Action Cable across multiple processes or servers, why do you need a pub/sub backend like Redis?

Question 3

What benefit do you get from splitting streams finer, e.g. turbo_stream_from [@project, "tasks"]?

Question 4

Why should you usually review N+1 queries and whole-list redraws before jumping straight to infra tuning when improving Hotwire app performance?

Question 5

When “realtime updates stop,” name two or more angles you’d use to narrow down the problem.


End-of-chapter mini-column: Hotwire ops leans heavily on “ordinary Rails” skill

Yukkuri Reimu “I thought Hotwire was newer tech, so operations would feel exotic too.”

Yukkuri Marisa “That’s the interesting half: some of it’s new—and half is very classic Rails.”

What pays off in Hotwire operations is quietly mundane:

- Stable Puma settings
- Kill N+1s
- Use caching effectively
- Trace with request_id
- Don’t miss errors

So solid “plain Rails operations” carries a lot of the weight. On top of that you add realtime concerns for Action Cable and Turbo Streams. ([Ruby on Rails Guides][2])

Yukkuri Marisa “Hotwire can look magical, but in production, grounded ops wins.”

Chapter 12: Hotwire's limits and when to use what

Introduction

Yukkuri Reimu "We've finally reached the final chapter. By this point you might even feel like, 'Maybe Hotwire can handle everything?'"

Yukkuri Marisa "That's the last big takeaway. Hotwire is plenty strong, but it's not a silver bullet. And in practice what matters isn't only what you can build with something — it's what you choose so you'll stay happy long term."

Yukkuri Reimu "So this chapter doesn't hype Hotwire blindly — we actually look at where it stops fitting."

Yukkuri Marisa "Right. To really master Hotwire, you need to spot where it shines and where it doesn't."

This chapter covers four topics:

  • 12.1 Compared with React/Vue
  • 12.2 Products it's a good fit for
  • 12.3 Cases where it's a poor fit
  • 12.4 Hybrid setups

12.1 Compared with React/Vue

12.1.1 Bottom line first: it looks like a fight, but the roles differ

Yukkuri Reimu "Are Hotwire and React rivals after all?"

Yukkuri Marisa "Sometimes it looks that way on the surface, but in reality they're good at different problems."

Roughly speaking:

Hotwire:
- Server HTML first
- Strong cohesion with Rails
- Strong at CRUD, forms, admin-style apps
- Makes it easy to keep JS minimal

React / Vue:
- Client-side state first
- Strong at complex interactions
- Strong component reuse
- Fits frontend-led architectures

12.1.2 Who owns rendering

The biggest difference is who drives what's shown.

Hotwire-style thinking

# app/controllers/tasks_controller.rb
def index
  @tasks = Task.order(created_at: :desc)
end
<!-- app/views/tasks/index.html.erb -->
<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

Here the flow is:

Server builds HTML
↓
Browser displays it
↓
Turbo swaps pieces when needed

React-style thinking

import { useEffect, useState } from "react"

export default function TasksPage() {
  const [tasks, setTasks] = useState([])

  useEffect(() => {
    fetch("/api/tasks")
      .then((response) => response.json())
      .then((data) => setTasks(data))
  }, [])

  return (
    <div>
      {tasks.map((task) => (
        <section key={task.id}>
          <h2>{task.title}</h2>
        </section>
      ))}
    </div>
  )
}

Here the flow is:

Server returns JSON
↓
Client holds state
↓
Client renders the UI

12.1.3 State management weight differs

Yukkuri Reimu "With React I always get the impression the 'state management' story is huge."

Yukkuri Marisa "That's a major difference. With Hotwire you push state back toward the server as much as possible. With React/Vue the assumption is stronger that you keep state on the client."

For example, consider a list + detail + "editing" state.

Hotwire-leaning

- Current view lives in server-rendered HTML
- Edit forms also come from the server
- Client state stays minimal

React/Vue-leaning

- Selected task
- Whether you're editing
- In-progress form values
- Loading state
- Error state

You often hold these on the client.

So:

If you need heavy client state management, React/Vue feels natural
If you want to avoid holding state client-side, Hotwire feels natural

12.1.4 UI componentization: React/Vue often wins

React/Vue excel at assembling lots of complex, componentized UI.

For example:

- High-end data grids
- Complex filter panels
- Nested interactive forms
- Rich UIs that can live entirely on the client

These tend to fit React/Vue more naturally.

Hotwire, on the other hand, is built around:

- Returning HTML fragments
- Using Stimulus only where you need motion

So building huge client-side UIs end to end isn't its strong suit.


12.1.5 Developer experience differs

The day-to-day feel of development is quite different too.

Hotwire:
- Feels like an extension of Rails
- ERB / partials / helpers / ViewComponent at the core
- Familiar to backend-leaning engineers

React / Vue:
- Tends toward frontend-first architecture
- Strong component mindset
- More time with npm, bundlers, state libraries

Yukkuri Reimu "So it's not 'which is better' — it's which kind of complexity you're willing to own."

Yukkuri Marisa "That's the most important takeaway."


12.1.6 Summary table

+----------------------+-------------------------+---------------------------+
| Angle                | Hotwire                 | React / Vue               |
+----------------------+-------------------------+---------------------------+
| Who renders          | Server HTML             | Client UI                 |
| Transport            | HTML / turbo-stream     | JSON / GraphQL, etc.      |
| State management     | Easier to keep small    | Tends to grow             |
| Fit with Rails       | Very good               | Split design is common    |
| CRUD / forms         | Very strong             | Possible but can be heavy |
| Complex UI           | Often a poor fit        | Strong                    |
| Team shape           | Full-stack friendly     | FE/BE split friendly      |
+----------------------+-------------------------+---------------------------+

12.1.7 Not "throw away React" — "use React only where needed"

Yukkuri Reimu "So if I'm on Hotwire, React isn't necessarily the enemy anymore."

Yukkuri Marisa "If anything it's the opposite. Understanding Hotwire makes it clear where React should be used — and only there."

So it's not:

Everything in React

or:

Everything in Hotwire

but:

Mostly Hotwire
React/Vue only for the genuinely complex UI

That judgment gets easier.


12.2 Products it's a good fit for

12.2.1 Strong for "ordinary" web apps first

Yukkuri Reimu "Concretely, what products does Hotwire fit?"

Yukkuri Marisa "First and biggest: form-heavy, CRUD-heavy ordinary web apps."

For example:

- Admin consoles
- Internal business systems
- CMS
- Task tools
- Booking systems
- Inquiry / ticket handling
- Sales support tools

In apps like these, Hotwire's strengths show through pretty directly.


12.2.2 Form-heavy apps

When there are lots of forms, Rails' strengths carry straight through.

<%= form_with model: @task do |form| %>
  <%= form.text_field :title %>
  <%= form.select :status, Task::STATUSES %>
  <%= form.submit %>
<% end %>

Add Hotwire on top and you naturally get:

- List updates right after save
- Re-render in place on validation errors
- Modal forms
- Inline editing

So the winning pattern is:

Take what Rails was already good at
Polish it with modern UX

12.2.3 Products that want presentation logic on the server

Hotwire fits well when you have constraints like:

- You don't want to split out APIs unnecessarily
- You want presentation logic to live in Rails
- Backend and UI are touched by the same team
- You want views managed as ERB / components

In those situations Hotwire is often a solid choice.


12.2.4 Apps where SEO and first paint matter

Because Hotwire is server-HTML-first, shipping a straightforward initial render is easy.

Good fits:

- The main app behind login
- Services with some public pages
- Admin + public pages in one app

Of course publicly exposed products that need extreme frontend tuning are a separate discussion — but at least you don't have to default everything to CSR from day one.


12.2.5 Small full-stack teams

Yukkuri Reimu "The people side matters too."

Yukkuri Marisa "It matters a lot. Hotwire pairs well with small teams shipping Rails end to end."

For example:

- Two to five Rails engineers
- No dedicated frontend role — or a thin one
- Want to ship fast and iterate
- Integrated development beats API-first split for speed

Under those conditions Hotwire is often a strong choice.


12.2.6 Summary: traits of a good fit

- Lots of CRUD
- Lots of forms
- List / detail / edit as the core loop
- Real-time features only need to be "good enough"
- You don't want client state to balloon
- You want to move fast in Rails

12.3 Cases where it's a poor fit

12.3.1 "Client-first" UIs get painful fast

Yukkuri Reimu "So conversely, where shouldn't we force Hotwire?"

Yukkuri Marisa "The clearest case is UIs that hold a ton of state on the client."

For example:

- Full spreadsheet-grade grids
- Design tools
- No-code editors
- Complex drag-and-drop builders
- Shape editing that lives entirely in the browser

These tend to get painful with Hotwire.


12.3.2 Offline-heavy apps and local state as the source of truth

Hotwire basically assumes:

Ask the server
↓
Get HTML back
↓
Update

So requirements like these don't fit well:

- Offline-first
- Long sessions on flaky networks
- Complex working state stored mainly in the browser
- Heavy editing without constant server round-trips

Here frameworks built around client state are more natural.


12.3.3 Sharing one API across many clients

If you want:

- Web
- iOS
- Android
- Partner-facing APIs

all on one backend, designing around JSON / GraphQL from the start is often cleaner.

Hotwire is strongly HTML-first, so you often end up with:

Web on Hotwire
Mobile on a separate API

That's not inherently wrong — but if a shared multi-client API is the star from day one, another shape is usually more natural.


12.3.4 Large dedicated frontend organizations

Yukkuri Reimu "Org constraints matter."

Yukkuri Marisa "They really do. If frontend specialists dominate and design is fully split, Hotwire's payoff can shrink."

For example:

- A design-system team exists
- Multiple dedicated frontend engineers
- You want the web frontend run as its own app
- FE owns SSR / CSR / BFF end to end

Then React/Vue/Nuxt/Next-style setups often match the org better.


12.3.5 Signs you're overdoing Frames / Streams

If more of these show up, you might be stretching Hotwire:

- Frames nested too deep
- Stream templates ballooning
- Stimulus controllers ballooning
- More exceptions where you dodge Turbo
- More state you wish lived on the client

In code, a giant controller like this is a warning:

// app/javascript/controllers/editor_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [
    "toolbar",
    "canvas",
    "layers",
    "selection",
    "history",
    "preview",
    "zoom",
    "export"
  ]

  connect() {}
  selectItem() {}
  moveItem() {}
  resizeItem() {}
  undo() {}
  redo() {}
  zoomIn() {}
  zoomOut() {}
  export() {}
  saveDraft() {}
  syncState() {}
}

Yukkuri Reimu "That's past 'a little sprinkle of JS' territory."

Yukkuri Marisa "Yeah. Once Stimulus gets this fat, it's time to rethink the design."


12.3.6 Summary: traits of a poor fit

- Heavy client state
- Offline-first
- Browser-native editing as the main event
- Shared multi-client API as the center of gravity
- FE-led teams owning the stack

12.4 Hybrid setups

12.4.1 In practice it's rarely "all one or the other"

Yukkuri Reimu "Hearing all this, hybrid setups sound pretty realistic."

Yukkuri Marisa "They are. In production mostly Hotwire with React/Vue in pockets is very workable."


12.4.2 A hybrid pattern worth recommending

The usual winning pattern:

- Whole screens: Rails + Hotwire
- Only specific complex widgets: React/Vue

For example:

- List/detail/CRUD in Hotwire
- Only the advanced graph editor in React
- Only the complex filter builder in Vue
- Only image cropping UI in React

That keeps overall complexity down while pulling specialist tools only where it's genuinely hard.


12.4.3 Embedded components

Imagine embedding a React widget on part of a task detail page.

<!-- app/views/tasks/show.html.erb -->
<h1><%= @task.title %></h1>

<p><%= @task.description %></p>

<div
  id="task-chart-root"
  data-task-id="<%= @task.id %>"
></div>

React-side sketch:

import { createRoot } from "react-dom/client"
import TaskChart from "./TaskChart"

document.addEventListener("turbo:load", () => {
  const rootElement = document.getElementById("task-chart-root")
  if (!rootElement) return

  const root = createRoot(rootElement)
  root.render(<TaskChart taskId={rootElement.dataset.taskId} />)
})

This way you park client-side UI inside Hotwire-rendered screens where needed.


12.4.4 Splitting by page

Another hybrid is splitting by screen.

- Admin: Hotwire
- Some end-user flows: React SPA

For example:

/admin/...         → Rails + Hotwire
/app/designer/...  → React
/app/dashboard/... → Hotwire

Hard screens can grow somewhat like separate apps.


12.4.5 You'll split APIs too

With hybrids, even inside one Rails app you may return different shapes:

# config/routes.rb
Rails.application.routes.draw do
  resources :tasks

  namespace :api do
    resources :tasks, only: [:index, :show, :update]
  end
end

Hotwire screens use normal HTML; React/Vue hit APIs.

Yukkuri Reimu "It gets a bit more complex, but lighter than splitting everything."

Yukkuri Marisa "Right. API only where needed is a pretty workable compromise."


12.4.6 Keep Stimulus vs React boundaries clear

In hybrids watch Stimulus and React not stepping on each other.

Bad patterns:

- Same DOM touched by both Stimulus and React
- Turbo updates trash React-owned regions
- Stimulus attaching behavior inside React-managed DOM

Prefer:

- Clear roots React owns
- Inside those roots: React only
- Outside: Hotwire / Stimulus

DOM ownership boundaries matter.


12.4.7 When hybrid fits

Hybrid tends to work when:

- Mostly CRUD but one pocket is wildly complex
- You want to reuse Rails assets
- But some UI really should be frontend-led
- Full SPA isn't justified

If instead:

- Almost everything is complex client UI

starting React/Vue-first is usually clearer.


12.4.8 Closing thought

Yukkuri Reimu "So tech choices aren't about believing in your favorite stack — they're about where you put complexity."

Yukkuri Marisa "Exactly. Hotwire is a strong choice when you don't want to drag unnecessary complexity onto the client."

When you need more:

- React/Vue in pockets only
- APIs in pockets only
- Heavy frontend stacks in pockets only

That's a very practical answer.


Chapter summary

Yukkuri Reimu "Feels like a proper finale — tidy. Not just 'Hotwire is strong' but where to stop."

Yukkuri Marisa "That's what matters most. The chapter boils down to this:"

- Hotwire vs React/Vue differs most in server HTML vs client state
- Hotwire shines on CRUD, forms, admin, internal tools
- Heavy client state or offline-first apps get painful fast with Hotwire
- Multi-client APIs or large FE-only teams often suit React/Vue-style setups
- In practice 'mostly Hotwire, React/Vue in pockets' hybrids work well
- Tech choice is fundamentally deciding where complexity lives

Exercises

Question 1

What is the biggest architectural difference between Hotwire and React/Vue?

Question 2

Name two or more traits of products Hotwire fits well.

Question 3

Hotwire fits poorly when client state is heavy. Give two or more examples.

Question 4

In a hybrid setup, why should you clearly separate DOM regions owned by React from those managed by Stimulus/Turbo?

Question 5

Explain why committing entirely to "all Hotwire" or "all React" up front is often a bad idea.


End-of-chapter mini-column: Learning Hotwire is worth it because your options multiply

Yukkuri Reimu "In the end Hotwire feels less like 'instead of React' and more like another weapon when you fight in Rails."

Yukkuri Marisa "That's pretty much it."

Learning Hotwire isn't only about memorizing a new stack.

- You can judge how far server HTML can carry you
- You can tell when client state management is truly necessary
- You can narrow where React/Vue belong
- You gain freedom designing Rails apps

Knowing Hotwire means you aren't stuck with:

"Everything must be an SPA"

as your only realistic path.

Yukkuri Marisa "Strength in tech isn't believing in one thing — it's knowing how to choose."


Appendix A: Turbo / Stimulus cheat sheet

Introduction

Yukkuri Reimu "End-of-book style — I want something I can skim fast."

Yukkuri Marisa "Yeah. Think of this less as a 'read cover to cover' chapter and more as notes you keep beside you while coding."


A.1 Turbo Drive cheat sheet

Basic import

// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"

<%= link_to "Show", task_path(task) %>

Disabling Turbo

<%= link_to "Normal visit", task_path(task), data: { turbo: false } %>
<%= form_with model: @task, data: { turbo: false } do |form| %>
  ...
<% end %>

Common events

document.addEventListener("turbo:load", () => {
  console.log("loaded")
})

document.addEventListener("turbo:before-visit", (event) => {
  console.log("before visit", event.detail.url)
})

document.addEventListener("turbo:visit", (event) => {
  console.log("visit", event.detail.url)
})

document.addEventListener("turbo:submit-start", (event) => {
  console.log("submit start", event.target)
})

document.addEventListener("turbo:submit-end", (event) => {
  console.log("submit end", event.detail)
})

Request a full page reload

<%= turbo_page_requires_reload %>

A.2 Turbo Frames cheat sheet

Define a frame

<%= turbo_frame_tag "task_details" do %>
  <p>Select a task</p>
<% end %>

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

Response must use the same frame id

<%= turbo_frame_tag "task_details" do %>
  <h2><%= @task.title %></h2>
<% end %>

Break out to full page navigation

<%= link_to "Back to tasks", tasks_path, data: { turbo_frame: "_top" } %>

Empty modal frame

<!-- layout -->
<%= turbo_frame_tag "modal" %>
<%= link_to "New task", new_task_path, data: { turbo_frame: "modal" } %>

A.3 Turbo Streams cheat sheet

List region

<div id="tasks">
  <% @tasks.each do |task| %>
    <%= render "task_card", task: task %>
  <% end %>
</div>

append

<%= turbo_stream.append "tasks", partial: "tasks/task_card", locals: { task: @task } %>

prepend

<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>

replace

<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

update

<%= turbo_stream.update "flash" do %>
  <%= render "shared/flash", notice: "Saved!" %>
<% end %>

remove

<%= turbo_stream.remove @task %>

Multiple updates in one response

<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>
<%= turbo_stream.replace "task_form", partial: "tasks/form", locals: { task: Task.new } %>
<%= turbo_stream.update "flash", "Task created." %>

Controller sketch

def create
  @task = Task.new(task_params)

  respond_to do |format|
    if @task.save
      format.turbo_stream
      format.html { redirect_to tasks_path, notice: "Task was successfully created." }
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

Subscribe

<%= turbo_stream_from "tasks" %>

Broadcast

after_create_commit -> {
  broadcast_prepend_to "tasks",
    target: "tasks",
    partial: "tasks/task_card",
    locals: { task: self }
}

A.4 Stimulus cheat sheet

Basic shape

// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    console.log("connected")
  }
}
<div data-controller="hello"></div>

action

<button data-action="click->hello#greet">Hello</button>
greet() {
  alert("Hello")
}

targets

static targets = ["input", "output"]
<input data-hello-target="input">
<p data-hello-target="output"></p>
this.inputTarget.value
this.outputTarget.textContent = "Updated"

values

static values = { count: Number }
<div data-controller="counter" data-counter-count-value="3"></div>
this.countValue
this.countValue = 10

classes

static classes = ["active"]
<div data-controller="menu" data-menu-active-class="is-active"></div>
this.element.classList.add(this.activeClass)

Multiple targets

this.itemTargets.forEach((item) => {
  console.log(item.textContent)
})

Common events

data-action="
  click->controller#method
  input->controller#method
  submit->controller#method
  change->controller#method
"

A.5 Turbo × Stimulus quick reference

Yukkuri Reimu "I still get stuck choosing between them."

Yukkuri Marisa "When that happens, think along these lines."

Add one row to the list after save              → Turbo Streams
Swap part of a list for detail                   → Turbo Frames
Make navigation feel snappier                    → Turbo Drive
Show a character count                           → Stimulus
Fine-grained modal open/close                    → Stimulus
Change button label while submitting             → Stimulus
Sync updates across users                        → Turbo Streams + Action Cable

Appendix B: Common errors and fixes

B.1 "Clicking doesn't update the Turbo Frame — full navigation happens"

Yukkuri Reimu "Frames should work but sometimes it full navigates."

Yukkuri Marisa "The most common causes look like this."

Cause 1: Missing data-turbo-frame

<%= link_to task.title, task_path(task) %>

Fix:

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

Cause 2: Response has no matching frame id

Wrong:

<%= turbo_frame_tag "details" do %>
  ...
<% end %>

Right:

<%= turbo_frame_tag "task_details" do %>
  ...
<% end %>

Cause 3: Turbo not loaded

Confirm:

import "@hotwired/turbo-rails"

is in app/javascript/application.js.


B.2 "Turbo Stream doesn't apply — falls back to HTML navigation"

Cause 1: Missing respond_to or format.turbo_stream

def create
  @task = Task.new(task_params)

  respond_to do |format|
    if @task.save
      format.turbo_stream
      format.html { redirect_to tasks_path }
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

Cause 2: Wrong template names (create.turbo_stream.erb, etc.)

Correct examples:

app/views/tasks/create.turbo_stream.erb
app/views/tasks/update.turbo_stream.erb
app/views/tasks/destroy.turbo_stream.erb

Cause 3: Target id missing

You need:

<div id="tasks"></div>

for:

<%= turbo_stream.append "tasks", ... %>

B.3 "The same task gets added twice"

Yukkuri Reimu "This one's sneakily painful."

Yukkuri Marisa "Pretty common."

Cause: Turbo Stream response from the controller and a broadcast

# controller
format.turbo_stream
# model
after_create_commit -> { broadcast_prepend_to "tasks" }

Doing both duplicates the append.

Fix

- Updates for your own request: controller
- Sync for everyone else: broadcast

Pick a policy and separate concerns.


B.4 "The modal shows the full list screen"

Wrong:

<%= link_to "Back", tasks_path %>

Inside a modal this may update the modal frame.

Fix:

<%= link_to "Back", tasks_path, data: { turbo_frame: "_top" } %>

B.5 "Stimulus controller doesn't run"

Cause 1: Filename doesn't match data-controller

hello_controller.js
↓
data-controller="hello"
length_counter_controller.js
↓
data-controller="length-counter"

Cause 2: Not registered in controllers/index.js

import { application } from "controllers/application"
import HelloController from "./hello_controller"

application.register("hello", HelloController)

Cause 3: Target names don't match

JS:

static targets = ["output"]

HTML:

<p data-hello-target="output"></p>

B.6 "After Turbo navigation JS init doesn't run"

Cause: Relying on DOMContentLoaded

Bad:

document.addEventListener("DOMContentLoaded", () => {
  console.log("only once")
})

Fix:

document.addEventListener("turbo:load", () => {
  console.log("after every Turbo visit")
})

B.7 "Events fire twice"

Cause: Adding listeners on every turbo:load

Bad:

document.addEventListener("turbo:load", () => {
  const button = document.getElementById("danger-button")
  if (!button) return

  button.addEventListener("click", () => {
    alert("clicked")
  })
})

Fix 1: Use onclick

document.addEventListener("turbo:load", () => {
  const button = document.getElementById("danger-button")
  if (!button) return

  button.onclick = () => {
    alert("clicked")
  }
})

Fix 2: Move to Stimulus

export default class extends Controller {
  click() {
    alert("clicked")
  }
}
<button data-controller="button" data-action="click->button#click">
  Click
</button>

B.8 "Action Cable doesn't sync"

Checklist

- Is /cable reachable?
- Is turbo_stream_from present?
- Are broadcast_* calls happening?
- Is config/cable.yml correct?
- Is Redis pointing at the right place?

Appendix C: Hotwire debugging guide

C.1 Debugging mindset

Yukkuri Reimu "Hotwire feels almost magical — when it breaks it's hard to know what to check."

Yukkuri Marisa "That's why order matters. Hotwire debugging isn't guesswork — slice by layer."

Suggested order:

1. Is the HTML correct?
2. Are Turbo/Stimulus loaded?
3. Is the request shape what you expect?
4. Is the response what you expect?
5. Does the DOM update target exist?
6. Are Cable/Redis healthy?

C.2 Start with HTML

First checks:

- ids present?
- data-controller correct?
- data-action correct?
- data-xxx-target correct?
- turbo-frame ids aligned?

Example:

<div id="tasks">
  ...
</div>

<%= turbo_frame_tag "task_details" do %>
  ...
<% end %>

<div data-controller="counter">
  <button data-action="click->counter#increment">+1</button>
</div>

Yukkuri Reimu "A lot really does fix itself if you 'just look at HTML first'."

Yukkuri Marisa "It really does. Hotwire is HTML-first, so HTML is the first place to look."


C.3 Log Turbo events

document.addEventListener("turbo:click", (event) => {
  console.log("turbo:click", event.target)
})

document.addEventListener("turbo:before-visit", (event) => {
  console.log("turbo:before-visit", event.detail.url)
})

document.addEventListener("turbo:visit", (event) => {
  console.log("turbo:visit", event.detail.url)
})

document.addEventListener("turbo:load", () => {
  console.log("turbo:load")
})

document.addEventListener("turbo:submit-start", (event) => {
  console.log("turbo:submit-start", event.target)
})

document.addEventListener("turbo:submit-end", (event) => {
  console.log("turbo:submit-end", event.detail)
})

This shows:

- Whether Turbo sees events
- Whether submit fired
- Whether a visit occurred

C.4 Confirm Stimulus connection

Drop this in each controller first:

connect() {
  console.log("connected", this.element)
}

Optionally:

disconnect() {
  console.log("disconnected", this.element)
}

This shows:

- Whether the controller connected at all
- Whether it reconnects after Turbo updates

C.5 Read server logs

On the Rails side, even seeing whether the action ran helps.

def create
  Rails.logger.info("TasksController#create called")
  ...
end

You can also log the format:

def create
  Rails.logger.info("request.format = #{request.format}")
  ...
end

That shows:

text/vnd.turbo-stream.html

vs:

text/html

C.6 Inspect response bodies

When Turbo Stream misbehaves, read the response.

Expected shape:

<turbo-stream action="append" target="tasks">
  <template>
    ...
  </template>
</turbo-stream>

If you get plain HTML, you may not be hitting the format.turbo_stream branch.


C.7 Temporarily disable Turbo

Yukkuri Reimu "Sometimes I want to know: is Turbo wrong, or is the HTML itself wrong?"

Yukkuri Marisa "Do this."

<%= link_to "Open", task_path(task), data: { turbo: false } %>
<%= form_with model: @task, data: { turbo: false } do |form| %>
  ...
<% end %>

That restores normal browser behavior so you can separate:

- Turbo-specific issues
- Broken Rails views either way

C.8 Debug Cable

List view:

<%= turbo_stream_from "tasks" %>

Model:

after_create_commit -> {
  broadcast_prepend_to "tasks",
    target: "tasks",
    partial: "tasks/task_card",
    locals: { task: self }
}

If sync fails:

1. Browser Network tab: watch /cable
2. WebSocket connected?
3. Server logs: broadcasts happening?
4. Stream subscription names match?

C.9 Handy "sanity check" snippets

DOM existence

console.log(document.getElementById("tasks"))
console.log(document.querySelector('[data-controller="counter"]'))

Current HTML

console.log(document.body.innerHTML)

Stimulus targets

connect() {
  console.log(this.hasOutputTarget)
  console.log(this.outputTarget)
}

Request format

Rails.logger.info("Accept header: #{request.headers['Accept']}")
Rails.logger.info("request.format: #{request.format}")

C.10 Debugging flow summary

Something's wrong
↓
Look at HTML
↓
Log Turbo/Stimulus events
↓
Server logs: format and action
↓
Inspect response body
↓
Disable Turbo if needed
↓
For Cable: check connection and broadcasts

Appendix D: Reference resources

D.1 What to reread first

Yukkuri Reimu "At the end I want a clear map of what to revisit."

Yukkuri Marisa "So you don't wander after finishing the book — here's a purpose-based index."


D.2 Suggested learning order

1. Foundations

- Rails CRUD
- REST
- Partials
- form_with

2. Hotwire core

- Turbo Drive
- Turbo Frames
- Turbo Streams

3. Supporting JS

- Stimulus controller / target / action
- connect / disconnect

4. Productionizing

- ViewComponent
- Form objects / service objects
- Action Cable
- Tests
- Operations

D.3 Where to look when stuck

Weird page navigation

→ Turbo Drive
→ turbo:load
→ data-turbo="false"

Partial updates don't apply

→ Turbo Frames
→ Check frame id match
→ Check _top

Create/update/delete don't reflect immediately

→ Turbo Streams
→ create/update/destroy.turbo_stream.erb
→ Check target ids

Fine-grained JS doesn't run

→ Stimulus
→ data-controller
→ data-action
→ targets
→ connect logs

Multi-user sync doesn't work

→ turbo_stream_from
→ broadcast_*
→ /cable
→ Redis

D.4 Implementation pattern recap

Prepend to list

<%= turbo_stream.prepend "tasks", partial: "tasks/task_card", locals: { task: @task } %>

Replace row

<%= turbo_stream.replace @task, partial: "tasks/task_card", locals: { task: @task } %>

Remove row

<%= turbo_stream.remove @task %>

Frame update

<%= link_to task.title, task_path(task), data: { turbo_frame: "task_details" } %>

Stimulus action

<button data-action="click->counter#increment">+1</button>

D.5 Topics to go deeper after the book

Yukkuri Reimu "After this book, what should I dig into next?"

Yukkuri Marisa "These are solid next steps."

- Large-scale view design with ViewComponent
- Turbo Streams + authorization
- Running Action Cable in production
- Splitting Stimulus responsibilities
- Rails caching strategy
- Stabilizing system tests
- Hybrid setups mixing in React/Vue

D.6 How to use the appendices

A: Cheat sheets to keep beside you while coding
B: Root-cause patterns for common mistakes
C: Step-by-step when you don't know where to look
D: Jumping-off points for continued learning

Yukkuri Reimu "Appendices get skipped a lot — but they're maybe the most battle-useful part."

Yukkuri Marisa "Right. Understand in the main text, use the appendices. That's strongest."


Appendices summary

Yukkuri Reimu "That's the end matter complete. Starting to feel like a genuinely usable book."

Yukkuri Marisa "Yeah. The appendices exist so the book stays useful on the job after you finish reading."

The appendices in short:

- Minimal Turbo / Stimulus snippets you can grab fast
- Common failure patterns you can squash by name
- Hotwire debugging as an ordered checklist
- Hooks from main text into implementation, investigation, and review

End-of-chapter mini-column: With Hotwire, being able to look things up beats rote memorization

Yukkuri Reimu "Honestly I'll never memorize every Turbo Stream shape and every Stimulus data attribute."

Yukkuri Marisa "That's fine. What wins in practice isn't the person who memorized everything — it's who can pull the right snippet when it matters."

Hotwire especially runs on stacks of small rules:

- Frame names must match
- Target ids must match
- data-controller / data-action / targets line up
- Check request format

That's exactly why cheat sheets and debugging sequences pay off.

Yukkuri Marisa "Hotwire isn't magic. But once you can reliably look up the rules, it becomes a seriously strong weapon."