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?
- After a form submit, append a new row to a list
- Show a live character count for an input
- 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
4.2.1 Ordinary link_to is Turbo-aware
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.
5.4.5 Links inside a frame becoming a “closed world” unintentionally
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_commitbroadcast
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"
Normal links
<%= 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 that updates a frame
<%= 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"
Cause: Link inside frame isn't _top
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."
