
Rails Hotwire
- 302 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
rails-hotwire is a Claude Code agent skill with 53 Hotwire best-practice rules for building interactive Ruby on Rails applications using Turbo Drive, Frames, Streams, morphing, and Stimulus controllers.
About
rails-hotwire is a dot-skills agent skill with 53 rules across 9 categories for Ruby on Rails Hotwire development. Categories span Turbo Drive navigation, Turbo Frames lazy loading, Turbo Streams DOM mutations, ActionCable broadcasting, Turbo 8 morphing, Stimulus controller patterns, performance optimization, architecture decisions, and Hotwire system tests. Developers reach for rails-hotwire when writing or reviewing turbo_frame_tag, turbo_stream_from, data-controller Stimulus code, broadcast refreshes, or choosing between Drive, Frames, Streams, and morphing in Rails 7+ SaaS applications.
- rails-hotwire
Rails Hotwire by the numbers
- 302 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,342 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill rails-hotwireAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 302 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you implement Turbo Frames in Rails 7?
Use rails-hotwire for development tasks
Who is it for?
Ruby on Rails developers building interactive server-rendered SaaS apps with Turbo Drive, Frames, Streams, ActionCable broadcasts, and Stimulus.
Skip if: Teams building React SPAs or Next.js frontends without any Ruby on Rails Hotwire stack in the codebase.
When should I use this skill?
A user works on Turbo Frames, Turbo Streams, Turbo Drive, Stimulus controllers, ActionCable broadcasts, or Hotwire performance in Rails code.
What you get
Hotwire-compliant Rails views with Turbo Drive, Frames, Streams, Stimulus controllers, and system tests following 53 documented best-practice rules.
- Hotwire-compliant Rails views and controllers
- Stimulus controller implementations
By the numbers
- Contains 53 Hotwire best-practice rules across 9 categories
- Covers 5 Turbo/Stimulus layers: Drive, Frames, Streams, Morphing, and Stimulus
Files
Community Rails Hotwire Best Practices
Comprehensive guide for building interactive Rails applications with Hotwire (Turbo + Stimulus), maintained by Community. Contains 53 rules across 9 categories, prioritized by impact to guide automated refactoring and code generation. Follows the DHH "One Person Framework" philosophy: the server renders HTML, Turbo makes it feel like an SPA, Stimulus adds the sprinkle of JS where needed.
When to Apply
Reference these guidelines when:
- Configuring Turbo Drive navigation, prefetching, and caching behavior
- Adding Turbo Frames for partial page updates and lazy loading
- Delivering Turbo Streams for surgical DOM mutations
- Broadcasting real-time updates over ActionCable
- Enabling Turbo 8 morphing for page refreshes
- Writing Stimulus controllers for client-side behavior
- Handling errors in Turbo navigation, frames, and WebSocket connections
- Choosing between Drive, Frames, Streams, Morphing, and Stimulus
- Testing Hotwire interactions in system and integration tests
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Navigation & Drive | CRITICAL | drive- |
| 2 | Turbo Frames | CRITICAL | frame- |
| 3 | Turbo Streams | HIGH | stream- |
| 4 | Broadcasting & Real-Time | HIGH | bcast- |
| 5 | Morphing & Page Refresh | HIGH | morph- |
| 6 | Performance Optimization | MEDIUM-HIGH | perf- |
| 7 | Stimulus Patterns | MEDIUM-HIGH | stim- |
| 8 | Architecture Decisions | MEDIUM | arch- |
| 9 | Testing Hotwire | MEDIUM | test- |
Quick Reference
1. Navigation & Drive (CRITICAL)
- `drive-prefetch-links` - Enable link prefetching for instant navigation
- `drive-form-submissions` - Use Turbo Drive for form submissions
- `drive-visit-actions` - Control history with visit actions
- `drive-cache-control` - Configure Turbo cache for preview pages
- `drive-selective-disable` - Disable Turbo Drive on incompatible pages
- `drive-progress-bar` - Customize the Turbo progress bar
- `drive-confirm-dialog` - Use data-turbo-confirm for destructive actions
- `drive-error-recovery` - Handle Turbo navigation and fetch errors gracefully
2. Turbo Frames (CRITICAL)
- `frame-lazy-loading` - Defer frame loading until viewport entry
- `frame-scope-navigation` - Scope navigation within frames
- `frame-src-navigation` - Use src for dynamic frame content
- `frame-break-out` - Handle frame breakout for redirects
- `frame-promote-visits` - Promote frame navigation to page visits
- `frame-dom-id` - Use dom_id for frame identification
- `frame-empty-state` - Provide meaningful frame loading states
3. Turbo Streams (HIGH)
- `stream-progressive-enhance` - Always provide HTML fallback for streams
- `stream-action-selection` - Choose the right stream action for DOM mutations
- `stream-multi-target` - Use targets for multi-element updates
- `stream-http-delivery` - Deliver streams via HTTP for form responses
- `stream-websocket-source` - Connect WebSocket sources in the body
- `stream-custom-actions` - Register custom stream actions for complex DOM updates
4. Broadcasting & Real-Time (HIGH)
- `bcast-model-broadcasts` - Use broadcasts_refreshes for simple model updates
- `bcast-debounce-n1` - Debounce broadcasts to prevent N+1 broadcast storms
- `bcast-scope-streams` - Scope broadcast streams to accounts or users
- `bcast-refresh-over-replace` - Prefer broadcast refresh over granular stream updates
- `bcast-avoid-view-logic-in-models` - Keep broadcasting logic out of models
- `bcast-signed-stream-names` - Use signed stream names for security
- `bcast-reconnect-handling` - Handle WebSocket disconnection and reconnection
5. Morphing & Page Refresh (HIGH)
- `morph-enable-page-refresh` - Enable morphing for page refreshes
- `morph-permanent-elements` - Mark stateful elements as permanent
- `morph-scroll-preservation` - Preserve scroll position during morphing
- `morph-stimulus-reconnect` - Handle Stimulus controller reconnection after morph
- `morph-frame-refresh` - Use refresh='morph' on frames for additive content
- `morph-vs-streams` - Choose morphing over complex stream orchestration
6. Performance Optimization (MEDIUM-HIGH)
- `perf-optimistic-ui` - Implement optimistic UI updates before server confirmation
- `perf-batch-streams` - Batch multiple stream updates into single responses
- `perf-frame-caching` - Cache Turbo Frame responses with fragment caching
- `perf-prefetch-strategic` - Disable prefetch on expensive endpoints
- `perf-memory-leak-prevention` - Clean up subscriptions and event listeners
7. Stimulus Patterns (MEDIUM-HIGH)
- `stim-outlets-communication` - Use outlets for cross-controller communication
- `stim-values-reactive-state` - Use Values API for reactive controller state
- `stim-action-descriptors` - Use declarative action descriptors over addEventListener
- `stim-small-reusable-controllers` - Keep Stimulus controllers small and reusable
8. Architecture Decisions (MEDIUM)
- `arch-progressive-enhancement` - Follow the progressive enhancement hierarchy
- `arch-frame-vs-stream-decision` - Use frames for scoped navigation, streams for multi-target updates
- `arch-importmap-management` - Pin JavaScript dependencies with import maps
- `arch-avoid-client-state` - Keep state on the server, not the client
- `arch-stimulus-boundaries` - Use Stimulus only for client-side behavior
9. Testing Hotwire (MEDIUM)
- `test-system-test-async` - Wait for Turbo updates in system tests
- `test-stream-assertions` - Use Turbo Stream test helpers in integration tests
- `test-broadcast-assertions` - Assert broadcasts with Turbo test helpers
- `test-frame-navigation` - Test frame navigation with scoped assertions
- `test-websocket-timing` - Handle WebSocket connection timing in system tests
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on performance and maintainability implications in the context of Hotwire and Rails.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{# Comments explaining the cost}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{# Comments explaining the benefit}{Optional sections as needed:}
Alternative ({context}): {Alternative approach when applicable}
When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.5",
"organization": "Community",
"technology": "Ruby on Rails Hotwire",
"date": "February 2026",
"abstract": "Comprehensive guide for building interactive Rails applications with Hotwire (Turbo + Stimulus), designed for AI agents and LLMs. Contains 53 rules across 9 categories, prioritized by impact from critical (Turbo Drive navigation, Turbo Frames architecture) to incremental (testing patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://turbo.hotwired.dev/handbook/",
"https://turbo.hotwired.dev/reference/attributes",
"https://stimulus.hotwired.dev/handbook/introduction",
"https://github.com/hotwired/turbo-rails",
"https://jetthoughts.com/blog/hotwire-turbo-8-performance-patterns-real-time-rails/",
"https://blog.superails.com/hotwire-turbo-8-morphing",
"https://labzero.com/blog/hotwire-decisions-when-to-use-turbo-frames-turbo-streams-and-stimulus",
"https://www.hotrails.dev/turbo-rails/turbo-streams"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Navigation & Drive (drive)
Impact: CRITICAL Description: Turbo Drive is the foundation of every Hotwire app — it intercepts all clicks and form submissions, so misconfiguration cascades into broken Frames, Streams, and 5× slower navigation (1200ms full reload vs 250ms Drive visit).
2. Turbo Frames (frame)
Impact: CRITICAL Description: Frames scope server responses to targeted page regions; wrong scoping causes full page reloads, broken back-button navigation, or invisible content failures that bypass error tracking.
3. Turbo Streams (stream)
Impact: HIGH Description: Streams deliver surgical DOM mutations via 9 built-in actions; choosing the wrong delivery method (HTTP vs WebSocket) or skipping progressive enhancement breaks non-JS clients and wastes server resources.
4. Broadcasting & Real-Time (bcast)
Impact: HIGH Description: N+1 broadcasts are the #1 Hotwire production performance killer — 1 create can trigger 100 broadcasts × 100 partial renders, degrading response times by orders of magnitude.
5. Morphing & Page Refresh (morph)
Impact: HIGH Description: Turbo 8 morphing updates only changed DOM nodes (23ms vs 180ms full replace — 7.8× faster), but requires explicit scroll preservation and permanent element configuration to avoid state loss.
6. Performance Optimization (perf)
Impact: MEDIUM-HIGH Description: Lazy-loaded frames, optimistic UI, and batched stream updates reduce initial load from 2.4s to 0.7s and cut perceived latency to near-zero for common interactions.
7. Stimulus Patterns (stim)
Impact: MEDIUM-HIGH Description: Stimulus controllers are the "sprinkle of JavaScript" in Hotwire — small, reusable behaviors that enhance server-rendered HTML. Misusing Stimulus to manage state, fetch data, or build DOM recreates the SPA problems Hotwire was designed to eliminate.
8. Architecture Decisions (arch)
Impact: MEDIUM Description: The progressive enhancement hierarchy (HTML → CSS → Drive → Frames → Streams → Stimulus) determines which tool to reach for; wrong choice leads to over-engineered JavaScript replacing server-rendered simplicity.
9. Testing Hotwire (test)
Impact: MEDIUM Description: Turbo's async DOM updates, WebSocket broadcasts, and frame navigations require specific Capybara patterns and test helpers to avoid flaky tests and false positives.
Keep State on the Server, Not the Client
Hotwire's core principle is that the server renders HTML and Turbo delivers it to the browser. When Stimulus controllers start fetching JSON, building HTML client-side, or managing complex application state in JavaScript values, you recreate the synchronization problems that Hotwire was designed to eliminate. Server-rendered HTML delivered via Frames or Streams is the single source of truth, and Stimulus should only manage ephemeral UI state like open/closed toggles.
Incorrect (Stimulus controller fetching JSON API and rendering HTML client-side):
// app/javascript/controllers/project_list_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { projects: Array, page: Number, filter: String }
connect() {
this.loadProjects()
}
async loadProjects() {
const response = await fetch(`/api/projects?page=${this.pageValue}&filter=${this.filterValue}`)
const data = await response.json()
this.projectsValue = data.projects
// Building HTML in JavaScript — defeats the purpose of Hotwire
this.element.innerHTML = this.projectsValue.map(project => `
<div class="project-card">
<h3>${project.name}</h3>
<p>${project.description}</p>
<span class="badge">${project.status}</span>
</div>
`).join("")
}
filter(event) {
this.filterValue = event.target.value
this.loadProjects()
}
}Correct (server renders HTML partial, Turbo Frame or Stream delivers it):
<%# app/views/projects/index.html.erb %>
<%= form_with url: projects_path, method: :get,
data: { turbo_frame: "project_list", turbo_action: "advance" } do |f| %>
<%= f.select :filter, ["All", "Active", "Archived"],
selected: params[:filter] || "All" ,
data: { action: "change->form#requestSubmit" } %>
<% end %>
<turbo-frame id="project_list">
<%= render @projects %>
<%== pagy_nav(@pagy) %>
</turbo-frame># app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
def index
@projects = Project.filter_by(params[:filter]).page(params[:page])
# Rails automatically renders HTML — no JSON API needed
# Turbo Frame scopes the response to just the project list
end
endUse Frames for Scoped Navigation, Streams for Multi-Target Updates
Turbo Frames and Turbo Streams solve different problems. Frames scope navigation to a single region of the page in response to user-initiated actions like clicking a link or submitting a form. Streams update one or more targets from server-initiated events or multi-target responses. Using the wrong primitive leads to convoluted workarounds, such as forcing Streams to handle simple inline editing or shoehorning Frames into multi-region update scenarios.
Incorrect (using Turbo Streams for simple inline edit that Frames handle perfectly):
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def edit
@comment = Comment.find(params[:id])
respond_to do |format|
format.turbo_stream {
render turbo_stream: turbo_stream.replace(
"comment_#{@comment.id}",
partial: "comments/form",
locals: { comment: @comment }
)
}
end
end
end<%# Unnecessarily complex: needs explicit turbo_stream response for a simple edit %>
<%= link_to "Edit", edit_comment_path(comment),
data: { turbo_stream: true } %>Correct (Frame for inline edit, Stream for updating notification count + last-activity timestamp simultaneously):
<%# Turbo Frame: perfect for scoped inline edit %>
<turbo-frame id="<%= dom_id(comment) %>">
<p><%= comment.body %></p>
<%= link_to "Edit", edit_comment_path(comment) %>
</turbo-frame>
<%# The edit form automatically scopes within the same frame %>
<%# app/views/comments/edit.html.erb %>
<turbo-frame id="<%= dom_id(@comment) %>">
<%= form_with model: @comment do |f| %>
<%= f.text_area :body %>
<%= f.submit "Save" %>
<%= link_to "Cancel", comment_path(@comment) %>
<% end %>
</turbo-frame><%# Turbo Streams: correct for multi-target server-initiated updates %>
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comments_count", Comment.count %>
<%= turbo_stream.replace "last_activity" do %>
<%= render "shared/last_activity", time: @comment.created_at %>
<% end %>Pin JavaScript Dependencies with Import Maps
Rails 7+ uses import maps by default to deliver JavaScript without a bundler. This removes the entire Node.js build toolchain (npm, webpack, esbuild) from the development and deployment pipeline, simplifying dependency management and reducing build times to zero. Using bin/importmap pin fetches ESM-compatible modules from CDNs and registers them for direct browser loading.
Incorrect (adding npm packages and configuring webpack/esbuild for client JS):
// package.json — unnecessary build tooling
{
"dependencies": {
"stimulus-autocomplete": "^3.1.0",
"sortablejs": "^1.15.0",
"chart.js": "^4.4.0"
},
"devDependencies": {
"esbuild": "^0.19.0",
"webpack": "^5.88.0"
}
}// app/javascript/application.js — requires bundler to resolve node_modules
import { Autocomplete } from "stimulus-autocomplete"
import Sortable from "sortablejs"
// Bundler must process and bundle these importsCorrect (bin/importmap pin, direct browser ESM loading):
# Terminal: pin dependencies from CDN
# bin/importmap pin stimulus-autocomplete
# bin/importmap pin sortablejs
# bin/importmap pin chart.js
# config/importmap.rb — generated by pin commands
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "stimulus-autocomplete", to: "https://ga.jspm.io/npm:stimulus-autocomplete@3.1.0/src/autocomplete.js"
pin "sortablejs", to: "https://ga.jspm.io/npm:sortablejs@1.15.0/modular/sortable.esm.js"
pin_all_from "app/javascript/controllers", under: "controllers"// app/javascript/application.js — no bundler needed
import "@hotwired/turbo-rails"
import "controllers"When NOT to use this pattern:
- Packages that only publish CommonJS (no ESM) and lack CDN builds
- Applications requiring tree-shaking for large dependency graphs
- TypeScript-first codebases that need transpilation
- Complex CSS pipelines (PostCSS, CSS modules) that require a build step
Follow the Progressive Enhancement Hierarchy
Hotwire provides a layered toolkit where each layer adds capability at the cost of complexity. Starting with the simplest tool that solves the problem keeps the codebase maintainable, reduces JavaScript surface area, and ensures graceful degradation. The hierarchy is: plain HTML and CSS first, then Turbo Drive, then Turbo Frames, then Turbo Streams, and only reach for Stimulus when genuine client-side behavior is needed.
Incorrect (reaching for Turbo Frames when plain HTML or Stimulus would suffice):
<%# app/views/projects/show.html.erb %>
<%# BAD: Using a Turbo Frame round-trip just to show/hide static content
that's already on the page. This adds unnecessary server requests. %>
<%= turbo_frame_tag "project_details",
src: project_details_path(@project) do %>
<p>Loading details...</p>
<% end %>
<%# Or worse: using Turbo Streams to toggle visibility %>
<%= button_to "Show Details", toggle_project_details_path(@project),
method: :post %># app/controllers/projects_controller.rb
# BAD: server endpoint just to toggle visibility of static content
def toggle_details
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace("project_details",
partial: "projects/details", locals: { project: @project })
end
end
endCorrect (using the simplest tool: HTML first, then CSS, then Stimulus, then Turbo):
<%# Step 1: Pure HTML — no JavaScript needed for a simple disclosure %>
<details>
<summary>Show Details</summary>
<p><%= @project.description %></p>
<p>Created: <%= @project.created_at.to_fs(:long) %></p>
</details>
<%# Step 2: If content is expensive to load, use a Turbo Frame %>
<details>
<summary>Show Team Members</summary>
<turbo-frame id="team_members" src="<%= project_team_members_path(@project) %>" loading="lazy">
<p>Loading...</p>
</turbo-frame>
</details>
<%# Step 3: Only use Stimulus when you need behavior CSS/HTML can't provide,
such as copying to clipboard or tracking character count %>
<div data-controller="clipboard">
<input type="text" value="<%= project_url(@project) %>" readonly data-clipboard-target="source">
<button data-action="click->clipboard#copy">Copy Link</button>
</div>Use Stimulus Only for Client-Side Behavior
Stimulus is designed for small, reusable behaviors that enhance server-rendered HTML: toggling visibility, copying to clipboard, counting characters, managing dropdowns. When a Stimulus controller starts fetching data, constructing DOM from templates, managing navigation, or orchestrating complex state machines, it has outgrown its purpose. Those responsibilities belong to Turbo Frames, Turbo Streams, or the server. Keep controllers small, focused, and portable.
Incorrect (Stimulus controller that fetches data, builds HTML, and manages complex state):
// app/javascript/controllers/task_board_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { tasks: Array, currentFilter: String, sortOrder: String }
async connect() {
const response = await fetch("/api/tasks")
this.tasksValue = await response.json()
this.render()
}
render() {
const filtered = this.tasksValue
.filter(t => this.currentFilterValue === "all" || t.status === this.currentFilterValue)
.sort((a, b) => this.sortOrderValue === "asc" ? a.priority - b.priority : b.priority - a.priority)
this.element.innerHTML = `
<div class="columns">
${["todo", "in_progress", "done"].map(status => `
<div class="column" data-status="${status}">
<h3>${status}</h3>
${filtered.filter(t => t.status === status).map(t => `
<div class="task-card" draggable="true" data-id="${t.id}">
<h4>${t.title}</h4>
<p>${t.assignee}</p>
</div>
`).join("")}
</div>
`).join("")}
</div>
`
}
}Correct (Stimulus for toggling visibility, Turbo Frame for loading content from server):
<%# Server renders the board — Turbo Frame handles loading/filtering %>
<%= form_with url: project_tasks_path(@project), method: :get,
data: { turbo_frame: "task_board" } do |f| %>
<%= f.select :filter, ["All", "To Do", "In Progress", "Done"],
data: { action: "change->form#requestSubmit" } %>
<% end %>
<turbo-frame id="task_board">
<div class="columns">
<% %w[todo in_progress done].each do |status| %>
<div class="column">
<h3><%= status.titleize %></h3>
<%= render @tasks.select { |t| t.status == status } %>
</div>
<% end %>
</div>
</turbo-frame>// Stimulus only handles pure client-side behavior
// app/javascript/controllers/character_count_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "counter"]
static values = { max: { type: Number, default: 280 } }
count() {
const remaining = this.maxValue - this.inputTarget.value.length
this.counterTarget.textContent = `${remaining} characters remaining`
this.counterTarget.classList.toggle("text-red-600", remaining < 0)
}
}Keep Broadcasting Logic Out of Models
When models contain inline partial rendering in after_commit callbacks, they become tightly coupled to the view layer. This makes testing harder (model tests need view fixtures), creates circular dependencies, and breaks when partials change. The declarative broadcasts_refreshes macro is acceptable because it only signals a refresh without rendering. For anything more complex, move broadcast logic to controllers or dedicated jobs where view context is appropriate.
Incorrect (after_create_commit with inline partial rendering in model):
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
belongs_to :notifiable, polymorphic: true
after_create_commit -> {
# BAD: model is rendering partials and constructing view-layer HTML
broadcast_append_to(
[user, :notifications],
target: "notification_list",
partial: "notifications/notification",
locals: { notification: self, show_timestamp: true, compact: false }
)
broadcast_update_to(
[user, :notifications],
target: "notification_badge",
html: ApplicationController.render(
partial: "shared/badge",
locals: { count: user.unread_notifications_count }
)
)
}
endCorrect (declarative broadcasts_refreshes or broadcasting from controller/job):
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
belongs_to :notifiable, polymorphic: true
# Option 1: Declarative — no view coupling, triggers page morph
broadcasts_refreshes_to ->(notification) { [notification.user, :notifications] }
end
# Option 2: When granular control is needed, use a job
# app/jobs/notification_broadcast_job.rb
class NotificationBroadcastJob < ApplicationJob
def perform(notification)
Turbo::StreamsChannel.broadcast_append_to(
[notification.user, :notifications],
target: "notification_list",
partial: "notifications/notification",
locals: { notification: notification }
)
end
end# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
# Option 3: Broadcast from the controller where view context exists
def mark_read
@notification = current_user.notifications.find(params[:id])
@notification.update!(read_at: Time.current)
respond_to do |format|
format.turbo_stream
format.html { redirect_to notifications_path }
end
end
endDebounce Broadcasts to Prevent N+1 Broadcast Storms
When a bulk operation updates many records (importing tasks, reordering a list, batch status changes), each after_commit callback fires its own broadcast. A 100-row import produces 100 WebSocket messages in rapid succession, overwhelming the Action Cable server and triggering 100 page morphs on every connected client. Suppress broadcasts during bulk operations and fire a single manual broadcast after the batch completes.
Incorrect (after_commit broadcasting directly, causing 100 broadcasts per bulk operation):
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
# Each save fires a separate broadcast
broadcasts_refreshes_to :project
# Bulk import triggers N broadcasts
def self.import_from_csv(project, csv_data)
csv_data.each do |row|
project.tasks.create!(
title: row["title"],
status: row["status"]
)
# => 100 rows = 100 broadcasts = 100 morphs for every viewer
end
end
endCorrect (suppress broadcasts during bulk operations, fire one at the end):
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
broadcasts_refreshes_to :project
def self.import_from_csv(project, csv_data)
# Suppress all Turbo broadcasts inside the block
Task.suppressing_turbo_broadcasts do
transaction do
csv_data.each do |row|
project.tasks.create!(
title: row["title"],
status: row["status"]
)
end
end
end
# Single broadcast after the entire import completes
Turbo::StreamsChannel.broadcast_refresh_to(project)
end
endAlternative (controller-level suppression for batch endpoints):
# app/controllers/tasks/imports_controller.rb
class Tasks::ImportsController < ApplicationController
def create
Task.suppressing_turbo_broadcasts do
@results = Task.import_from_csv(@project, parsed_csv)
end
# One broadcast for all connected viewers
Turbo::StreamsChannel.broadcast_refresh_to(@project)
redirect_to @project, notice: "Imported #{@results.count} tasks"
end
endWhen NOT to use this pattern:
- Single-record creates/updates —
broadcasts_refreshes_tois fine as-is - Real-time collaborative editing where each keystroke should broadcast
Reference: Turbo Broadcastable — turbo-rails
Use broadcasts_refreshes for Simple Model Updates
For straightforward real-time updates where all subscribers should see the latest state of a page, broadcasts_refreshes provides maximum value with minimal code. A single declaration in the model triggers a page morph for every subscribed client after create, update, or destroy. This eliminates the need to manually wire Action Cable channels, write JavaScript handlers, or maintain stream templates for each mutation type.
Incorrect (manually wiring ActionCable channels and JavaScript handlers):
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
after_create_commit -> { broadcast_append_to project, target: "tasks" }
after_update_commit -> { broadcast_replace_to project }
after_destroy_commit -> { broadcast_remove_to project }
end// app/javascript/channels/project_channel.js
import consumer from "./consumer"
// BAD: manual channel subscription duplicating what Turbo handles
consumer.subscriptions.create(
{ channel: "ProjectChannel", project_id: projectId },
{
received(data) {
const tasksContainer = document.getElementById("tasks");
tasksContainer.innerHTML = data.html;
},
}
);Correct (broadcasts_refreshes in model + turbo_stream_from in view):
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
broadcasts_refreshes_to :project
end<%# app/views/projects/show.html.erb %>
<%# Subscribe to the project's broadcast stream %>
<%= turbo_stream_from @project %>
<h1><%= @project.name %></h1>
<div id="tasks">
<%= render @project.tasks %>
</div>
<%# That's it. Any Task create/update/destroy triggers a page morph
for all users viewing this project. No JavaScript required. %>Handle WebSocket Disconnection and Reconnection
ActionCable reconnects automatically after a WebSocket disconnection, but any broadcasts sent during the downtime are lost — the client never receives them. After reconnection, the page displays stale data until the next broadcast or manual refresh. Detecting disconnection state and triggering a page refresh on reconnection ensures users always see current data.
Incorrect (no awareness of WebSocket state — stale data after reconnection):
<%# app/views/projects/show.html.erb %>
<%= turbo_stream_from @project %>
<div id="tasks">
<%= render @project.tasks %>
</div>
<%# If the user's Wi-Fi drops for 30 seconds and 3 tasks are created,
those broadcasts are lost. After reconnection the user sees
the old task list until someone creates yet another task. %>Correct (Stimulus controller monitors cable state and refreshes on reconnection):
<%# app/views/projects/show.html.erb %>
<%= turbo_stream_from @project %>
<div data-controller="cable-monitor"
data-cable-monitor-stale-class="border-yellow-400">
<div id="tasks">
<%= render @project.tasks %>
</div>
</div>// app/javascript/controllers/cable_monitor_controller.js
import { Controller } from "@hotwired/stimulus"
import { getConsumer } from "@hotwired/turbo-rails"
export default class extends Controller {
static classes = ["stale"]
connect() {
this.monitor = getConsumer().connection.monitor
this.checkInterval = setInterval(() => this.checkConnection(), 2000)
this.wasDisconnected = false
}
disconnect() {
clearInterval(this.checkInterval)
}
checkConnection() {
const connected = this.monitor.isRunning()
if (!connected && !this.wasDisconnected) {
this.wasDisconnected = true
this.element.classList.add(...this.staleClasses)
}
if (connected && this.wasDisconnected) {
this.wasDisconnected = false
this.element.classList.remove(...this.staleClasses)
// Refresh the page to catch up on missed broadcasts
Turbo.visit(window.location.href, { action: "replace" })
}
}
}When NOT to use this pattern:
- On pages without real-time broadcasts — the overhead of monitoring is unnecessary
- When using
broadcasts_refresheswith frequent updates — the next broadcast will naturally catch up
Reference: ActionCable Connection Monitor — Rails
Prefer Broadcast Refresh Over Granular Stream Updates
Granular stream broadcasts (broadcast_append_to, broadcast_replace_to, broadcast_remove_to) require you to specify the correct target, partial, and action for every mutation type. See also `morph-vs-streams` for the controller-side perspective. When a single model change affects multiple page sections (a counter, a list, a status badge), you need multiple broadcast calls with synchronized partials. broadcasts_refreshes triggers a single page morph that re-renders the current page server-side and patches only the differences, dramatically reducing code and eliminating target-mismatch bugs.
Incorrect (complex broadcast callbacks targeting multiple elements):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
after_create_commit -> {
broadcast_append_to [post, :comments],
target: "comments",
partial: "comments/comment"
broadcast_replace_to [post, :comments],
target: "comments_count",
html: "<span id='comments_count'>#{post.comments.count}</span>"
broadcast_replace_to [post, :comments],
target: "latest_activity",
partial: "posts/latest_activity",
locals: { post: post }
}
after_destroy_commit -> {
broadcast_remove_to [post, :comments]
broadcast_replace_to [post, :comments],
target: "comments_count",
html: "<span id='comments_count'>#{post.comments.count}</span>"
}
endCorrect (single broadcasts_refreshes that morphs the whole page):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
# One line replaces all the granular broadcast callbacks above.
# On any create/update/destroy, subscribers get a page refresh
# that morphs only the changed DOM elements.
broadcasts_refreshes_to :post
end<%# app/views/posts/show.html.erb %>
<%= turbo_stream_from @post %>
<h1><%= @post.title %></h1>
<span id="comments_count"><%= @post.comments.count %></span> comments
<div id="comments">
<%= render @post.comments %>
</div>
<div id="latest_activity">
<%= render "posts/latest_activity", post: @post %>
</div>
<%# All three sections (count, list, activity) update automatically
via a single page morph. No stream templates needed. %>Scope Broadcast Streams to Accounts or Users
Broadcasting to a generic stream name like "messages" means every subscriber receives every broadcast regardless of which account, project, or team they belong to. In a multi-tenant application, this leaks data across organizational boundaries. Always scope stream names to the owning resource (account, project, team) so that only authorized subscribers receive updates.
Incorrect (broadcasting to a global stream visible to all users):
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
# BAD: all users subscribed to "messages" see every message
broadcasts_refreshes_to "messages"
end<%# app/views/conversations/show.html.erb %>
<%# BAD: subscribes to a global stream — receives messages from all conversations %>
<%= turbo_stream_from "messages" %>
<div id="messages">
<%= render @conversation.messages %>
</div>Correct (broadcasting to scoped stream with resource association):
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
# Stream is scoped to the specific conversation
broadcasts_refreshes_to :conversation
end<%# app/views/conversations/show.html.erb %>
<%# Subscribes to this conversation's signed stream only %>
<%= turbo_stream_from @conversation %>
<div id="messages">
<%= render @conversation.messages %>
</div>
<%# For deeper scoping (e.g., account + resource), use an array: %>
<%# turbo_stream_from generates a signed name from all components,
preventing cross-tenant subscription even if IDs collide %>
<%= turbo_stream_from current_user.account, @conversation %>Use Signed Stream Names for Security
Turbo uses cryptographically signed stream names to prevent unauthorized clients from subscribing to arbitrary channels. The turbo_stream_from helper automatically signs the stream name using Rails' secret key base, and the Turbo::StreamsChannel verifies this signature on subscription. Manually constructing channel subscriptions with unsigned names bypasses this protection, allowing any client to listen to any stream by guessing the name.
Incorrect (broadcasting to predictable, unscoped stream names):
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
# BAD: broadcasting to a string literal without resource scoping.
# If two tenants share the same conversation ID (e.g., both have "1"),
# their messages leak across organizations.
broadcasts_refreshes_to "conversation_messages"
end<%# BAD: subscribing to a generic string name instead of a scoped resource.
Any user who guesses the stream name can subscribe. %>
<%= turbo_stream_from "conversation_messages" %>
<%# Also BAD: using just the record without tenant scoping in multi-tenant apps %>
<%= turbo_stream_from @conversation %>
<%# If tenant A's conversation #1 and tenant B's conversation #1 share the
same signed stream name, broadcasts leak between tenants. %>Correct (turbo_stream_from helper that auto-signs):
<%# app/views/projects/show.html.erb %>
<%# turbo_stream_from generates a signed stream name automatically.
The signature is verified server-side on subscription. %>
<%= turbo_stream_from @project, :messages %>
<%# For multiple stream sources on the same page: %>
<%= turbo_stream_from @project, :tasks %>
<%= turbo_stream_from current_user, :notifications %>
<div id="messages">
<%= render @project.messages %>
</div># When broadcasting from server code, use the same streamable:
Turbo::StreamsChannel.broadcast_refresh_to(@project, :messages)
# The stream name is signed consistently on both ends.
# Never pass raw/unsigned strings to broadcast methods.Configure Turbo Cache for Preview Pages
Turbo caches pages to show instant previews when navigating back. Elements like flash messages, modal overlays, and loading spinners persist in the cache and reappear as stale artifacts. Mark transient elements with data-turbo-temporary to strip them before caching, and use Turbo-Cache-Control headers to disable caching entirely on pages with sensitive or rapidly-changing data.
Incorrect (flash messages and modals persist in cache previews):
<%# app/views/layouts/application.html.erb %>
<div class="flash-messages">
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>">
<%= message %>
</div>
<% end %>
</div>
<%# Loading spinner shows in cached preview %>
<div id="loading-overlay" class="hidden">
<div class="spinner">Loading...</div>
</div>Correct (transient elements removed before caching):
<%# app/views/layouts/application.html.erb %>
<%# data-turbo-temporary removes this element before the page is cached %>
<div class="flash-messages" data-turbo-temporary>
<% flash.each do |type, message| %>
<div class="flash flash-<%= type %>">
<%= message %>
</div>
<% end %>
</div>
<%# Loading overlays should also be temporary %>
<div id="loading-overlay" class="hidden" data-turbo-temporary>
<div class="spinner">Loading...</div>
</div># app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
def show
# Prevent caching entirely for real-time data pages
response.set_header("Turbo-Cache-Control", "no-cache")
@metrics = Dashboard::Metrics.current
end
end
# app/controllers/checkout_controller.rb
class CheckoutController < ApplicationController
def show
# Prevent preview (still caches for restoration visits)
response.set_header("Turbo-Cache-Control", "no-preview")
@order = current_order
end
endUse data-turbo-confirm for Destructive Actions
Turbo intercepts all form submissions and link clicks, which means the traditional data: { confirm: "Are you sure?" } Rails helper still works but uses the browser's native window.confirm() dialog. For custom-styled confirmation modals, override Turbo.setConfirmMethod with a function that returns a Promise. This keeps destructive action protection declarative in HTML while supporting branded modal designs.
Incorrect (inline JavaScript onclick handler for confirmation):
<%# app/views/projects/_project.html.erb %>
<%= button_to "Delete", project_path(project), method: :delete,
onclick: "return confirm('Are you sure?')" %>
<%# Or worse: Stimulus controller doing the fetch manually %>
<button data-controller="delete"
data-action="click->delete#confirm"
data-delete-url-value="<%= project_path(project) %>">
Delete
</button>// BAD: manually handling the confirmation flow and submission
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
confirm() {
if (window.confirm("Are you sure?")) {
fetch(this.urlValue, { method: "DELETE", headers: { "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content } })
.then(() => window.location.reload())
}
}
}Correct (data-turbo-confirm with native or custom modal):
<%# Native browser confirm — works out of the box %>
<%= button_to "Delete", project_path(project), method: :delete,
form: { data: { turbo_confirm: "Delete this project? This cannot be undone." } } %>
<%# Works on links too %>
<%= link_to "Remove member", project_member_path(project, member),
data: { turbo_method: :delete, turbo_confirm: "Remove this team member?" } %>// For custom-styled modals, override Turbo.setConfirmMethod once in application.js
import "@hotwired/turbo-rails"
Turbo.setConfirmMethod((message, element) => {
// Return a Promise that resolves to true (proceed) or false (cancel)
return new Promise((resolve) => {
const dialog = document.getElementById("confirm-dialog")
dialog.querySelector("[data-message]").textContent = message
dialog.querySelector("[data-confirm]").onclick = () => {
dialog.close()
resolve(true)
}
dialog.querySelector("[data-cancel]").onclick = () => {
dialog.close()
resolve(false)
}
dialog.showModal()
})
})<%# app/views/layouts/application.html.erb %>
<dialog id="confirm-dialog" class="modal">
<p data-message></p>
<div class="modal-actions">
<button data-cancel class="btn btn-secondary">Cancel</button>
<button data-confirm class="btn btn-danger">Confirm</button>
</div>
</dialog>Reference: Turbo Handbook — Confirmation
Handle Turbo Navigation and Fetch Errors Gracefully
When Turbo Drive encounters a server error (500), network failure, or timeout, it either shows the raw error response or silently fails. When a Turbo Frame request fails, the frame goes blank with no feedback. Turbo emits events at each failure point (turbo:fetch-request-error, turbo:frame-missing, turbo:frame-render) that let you intercept errors and show meaningful feedback instead of leaving users staring at a broken page.
Incorrect (no error handling — server errors show raw HTML or blank frames):
<%# app/views/projects/show.html.erb %>
<%# If this frame's endpoint returns 500, the frame goes blank silently %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project),
loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
<%# No error handling configured — a network failure during Drive navigation
shows a blank page or the browser's default error %>Correct (event listeners for error recovery and user feedback):
// app/javascript/turbo_error_handler.js
// Handle network errors during Turbo Drive navigation
document.addEventListener("turbo:fetch-request-error", (event) => {
event.preventDefault()
const message = navigator.onLine
? "Something went wrong. Please try again."
: "You appear to be offline. Check your connection."
showFlash(message, "error")
})
// Handle missing frame content (frame response doesn't contain matching frame)
document.addEventListener("turbo:frame-missing", (event) => {
event.preventDefault()
const frame = event.target
frame.innerHTML = `
<div class="frame-error" role="alert">
<p>This content could not be loaded.</p>
<button onclick="this.closest('turbo-frame').reload()">Retry</button>
</div>
`
})
function showFlash(message, level) {
const flash = document.createElement("div")
flash.className = `flash flash-${level}`
flash.setAttribute("role", "alert")
flash.textContent = message
document.querySelector(".flash-messages")?.appendChild(flash)
setTimeout(() => flash.remove(), 5000)
}<%# app/views/layouts/application.html.erb %>
<%= javascript_importmap_tags %>
<div class="flash-messages" data-turbo-temporary>
<%# Flash container for both server and client-side messages %>
</div>
<%= yield %>When NOT to use this pattern:
- For API-only endpoints that don't serve HTML — use standard HTTP error handling
- In development mode where you want to see full error pages for debugging
Reference: Turbo Reference — Events
Use Turbo Drive for Form Submissions
Turbo Drive intercepts standard form submissions automatically, sending them as fetch requests and processing the response without a full page reload. Rails controllers must respond with 303 See Other status on redirects after non-GET requests (Rails does this by default with redirect_to). Avoid bypassing Turbo with manual fetch() calls for standard CRUD operations.
Incorrect (bypassing Turbo with manual JavaScript):
<%# Using JavaScript fetch instead of letting Turbo handle the form %>
<%= form_with model: @message, id: "message-form", data: { turbo: false } do |f| %>
<%= f.text_area :body %>
<%= f.submit "Send" %>
<% end %>
<script>
document.getElementById("message-form").addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target;
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { "Accept": "application/json" }
});
const data = await response.json();
// Manually update DOM...
document.getElementById("messages").insertAdjacentHTML("beforeend", data.html);
form.reset();
});
</script>Correct (standard form_with that Turbo intercepts automatically):
<%= form_with model: @message do |f| %>
<%= f.text_area :body %>
<%= f.submit "Send", data: { turbo_submits_with: "Sending..." } %>
<% end %># app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@message = current_user.messages.build(message_params)
if @message.save
# Rails automatically uses 303 See Other for redirect after POST
redirect_to project_messages_path(@message.project),
notice: "Message sent"
else
# 422 tells Turbo to render the form with validation errors
render :new, status: :unprocessable_entity
end
end
endEnable Link Prefetching for Instant Navigation
Turbo Drive prefetches links on hover (mouseenter) by default, making subsequent page loads feel instant. This eliminates the network round-trip delay users would otherwise experience after clicking. Disable prefetching selectively on expensive endpoints (dashboards, reports) to avoid unnecessary server load.
Incorrect (no prefetch awareness, slow navigation):
<%# All links prefetch by default — including expensive ones %>
<nav>
<%= link_to "Projects", projects_path %>
<%= link_to "Messages", messages_path %>
<%= link_to "Analytics Dashboard", analytics_path %>
<%= link_to "Export Report", reports_export_path %>
</nav>
<%# Or globally disabling prefetch, losing the benefit everywhere %>
<head>
<meta name="turbo-prefetch" content="false">
</head>See also: `perf-prefetch-strategic` for granular strategies on which endpoints to exclude.
Correct (default prefetch with selective opt-out on heavy endpoints):
<nav>
<%# These prefetch on hover by default — instant navigation %>
<%= link_to "Projects", projects_path %>
<%= link_to "Messages", messages_path %>
<%# Opt out on expensive endpoints to avoid unnecessary server load %>
<%= link_to "Analytics Dashboard", analytics_path,
data: { turbo_prefetch: false } %>
<%= link_to "Export Report", reports_export_path,
data: { turbo_prefetch: false } %>
</nav>
<%# Or disable prefetch for an entire section %>
<div data-turbo-prefetch="false">
<%= link_to "Heavy Report A", report_path(:a) %>
<%= link_to "Heavy Report B", report_path(:b) %>
</div>Customize the Turbo Progress Bar
Turbo shows a thin progress bar at the top of the page when navigation takes longer than 500ms (the default delay). For applications where most responses are fast but some are slow (reports, search), adjusting the delay and styling the bar prevents jarring UX: too short a delay causes flicker on fast pages, too long leaves users wondering if their click registered.
Incorrect (no visual feedback on slow navigations):
<%# No progress bar customization — default 500ms delay %>
<%# Users see nothing for 500ms, then a barely visible bar %>
<style>
/* No progress bar styles defined */
</style>// No configuration — some teams disable it entirely by mistake
import "@hotwired/turbo-rails"
// This removes all loading feedback
Turbo.setProgressBarDelay(999999)Correct (customized progress bar with appropriate delay):
// app/javascript/application.js
import "@hotwired/turbo-rails"
// Show progress bar after 300ms — fast enough to feel responsive
// without flickering on quick navigations
Turbo.setProgressBarDelay(300)/* app/assets/stylesheets/turbo.css */
.turbo-progress-bar {
height: 3px;
background: linear-gradient(
to right,
#6366f1, /* indigo-500 */
#8b5cf6 /* violet-500 */
);
/* Override the default animation for smoother feel */
transition: width 300ms ease-out;
}<%# For pages with known slow responses, show inline feedback too %>
<%= form_with url: search_projects_path, method: :get,
data: { turbo_action: "replace" } do |f| %>
<%= f.search_field :q, value: params[:q] %>
<%= f.submit "Search",
data: { turbo_submits_with: "Searching..." } %>
<% end %>Disable Turbo Drive on Incompatible Pages
Turbo Drive intercepts all link clicks and form submissions by default, which breaks external OAuth redirects, payment gateway forms, file downloads, and third-party JavaScript widgets that expect full page loads. Disable Turbo on specific elements with data-turbo="false" rather than disabling it globally, which would eliminate all Turbo benefits.
Incorrect (Turbo intercepting external redirects and payment forms):
<%# OAuth link intercepted by Turbo — redirect fails silently %>
<%= link_to "Sign in with Google",
user_google_oauth2_omniauth_authorize_path %>
<%# Stripe Checkout form broken by Turbo interception %>
<%= form_with url: create_checkout_session_path do |f| %>
<%= f.hidden_field :price_id, value: @price.id %>
<%= f.submit "Subscribe" %>
<% end %>
<%# File download intercepted instead of triggering browser download %>
<%= link_to "Download CSV", export_projects_path(format: :csv) %>Correct (Turbo disabled on specific incompatible elements):
<%# Disable Turbo for OAuth redirects %>
<%= link_to "Sign in with Google",
user_google_oauth2_omniauth_authorize_path,
data: { turbo: false } %>
<%# Disable Turbo for payment forms that redirect externally %>
<%= form_with url: create_checkout_session_path,
data: { turbo: false } do |f| %>
<%= f.hidden_field :price_id, value: @price.id %>
<%= f.submit "Subscribe" %>
<% end %>
<%# Disable Turbo for file downloads %>
<%= link_to "Download CSV", export_projects_path(format: :csv),
data: { turbo: false } %>
<%# Scope disabling to a container for third-party widgets %>
<div data-turbo="false">
<div id="hubspot-form"></div>
<script>
hbspt.forms.create({ portalId: "123", formId: "abc" });
</script>
</div>Control History with Visit Actions
Turbo Drive defaults to "advance" for link clicks, pushing a new entry onto the browser history stack. For search, filter, and sort interactions, use data-turbo-action="replace" to swap the current history entry instead. Without this, users pressing back after applying five filters must click back five times to leave the page.
Incorrect (every interaction pushes to history, breaking back button):
<%# Search form pushes a new history entry on every keystroke/submit %>
<%= form_with url: projects_path, method: :get do |f| %>
<%= f.search_field :q, value: params[:q] %>
<%= f.select :status, ["active", "archived"], selected: params[:status] %>
<%= f.submit "Search" %>
<% end %>
<%# Sort links each push a new history entry %>
<%= link_to "Sort by date", projects_path(sort: :created_at) %>
<%= link_to "Sort by name", projects_path(sort: :name) %>Correct (replace history for filters, advance for navigation):
<%# Search/filter form replaces the current history entry %>
<%= form_with url: projects_path, method: :get,
data: { turbo_action: "replace" } do |f| %>
<%= f.search_field :q, value: params[:q] %>
<%= f.select :status, ["active", "archived"], selected: params[:status] %>
<%= f.submit "Search" %>
<% end %>
<%# Sort links replace instead of pushing new history entries %>
<%= link_to "Sort by date", projects_path(sort: :created_at),
data: { turbo_action: "replace" } %>
<%= link_to "Sort by name", projects_path(sort: :name),
data: { turbo_action: "replace" } %>
<%# Navigation links correctly advance (default behavior) %>
<%= link_to "View project", project_path(@project) %>Handle Frame Breakout for Redirects
When a Turbo Frame request receives a response that does not contain a matching <turbo-frame> element, Turbo silently renders nothing -- the frame goes blank. This commonly happens when an authenticated frame request gets redirected to a login page, or when a frame action redirects to an unrelated page. Use target="_top" on the frame or add a turbo-visit-control meta tag on redirect target pages to force a full-page visit.
Incorrect (login redirect silently failing inside a frame):
<%# app/views/projects/show.html.erb %>
<%# When session expires, this frame request redirects to /login
but the login page has no matching frame — user sees blank space %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project) do %>
<p>Loading comments...</p>
<% end %># app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
private
def authenticate_user!
unless current_user
# This redirect breaks frame requests — login page
# won't have a matching turbo-frame tag
redirect_to login_path
end
end
endCorrect (proper breakout handling for auth and cross-page redirects):
<%# app/views/sessions/new.html.erb (login page) %>
<%# Force full-page reload when login page is loaded inside a frame %>
<head>
<meta name="turbo-visit-control" content="reload">
</head>
<h1>Sign in</h1>
<%= form_with url: session_path do |f| %>
<%= f.email_field :email %>
<%= f.password_field :password %>
<%= f.submit "Sign in" %>
<% end %># app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
private
def authenticate_user!
unless current_user
# Redirect normally — the meta tag on the login page handles breakout.
# Turbo fetches the redirect target, sees turbo-visit-control="reload",
# and triggers a full-page navigation automatically.
redirect_to login_path
end
end
end<%# Alternative: use target="_top" on the frame itself %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project),
target: "_top" do %>
<p>Loading comments...</p>
<% end %>Caveat: turbo-visit-control="reload" causes two GET requests — the first is the frame fetch that discovers the meta tag, and the second is the full-page reload Turbo triggers. Flash messages set during the redirect are consumed by the first request and lost before the second. If flash preservation matters, prefer handling the redirect in the controller with turbo_frame_request?:
# app/controllers/application_controller.rb
def authenticate_user!
unless current_user
if turbo_frame_request?
# Respond with a full-page redirect instead of rendering inside the frame
render turbo_stream: turbo_stream.action(:redirect, login_path)
else
redirect_to login_path
end
end
endUse dom_id for Frame Identification
Rails' dom_id helper generates unique, deterministic IDs from Active Record objects (e.g., message_42). When rendering collections of framed items, hardcoded string IDs collide and cause Turbo to match the wrong frame. Passing a model directly to turbo_frame_tag calls dom_id internally, ensuring each frame has a unique, consistent identifier that matches between the list view and the edit/show response.
Incorrect (hardcoded string IDs that collide in collections):
<%# app/views/messages/_message.html.erb %>
<%# Every message gets the same frame ID — Turbo can't distinguish them %>
<%= turbo_frame_tag "message" do %>
<div class="message">
<p><%= message.body %></p>
<%= link_to "Edit", edit_message_path(message) %>
</div>
<% end %>
<%# Or manually constructing IDs with inconsistent formats %>
<%= turbo_frame_tag "msg-#{message.id}" do %>
<div class="message">
<p><%= message.body %></p>
<%= link_to "Edit", edit_message_path(message) %>
</div>
<% end %>
<%# app/views/messages/edit.html.erb %>
<%# Must match the exact same format — easy to get wrong %>
<%= turbo_frame_tag "msg-#{@message.id}" do %>
<%= render "form", message: @message %>
<% end %>Correct (dom_id via model object for automatic, collision-free IDs):
<%# app/views/messages/_message.html.erb %>
<%# Passing the model directly calls dom_id — generates "message_42" %>
<%= turbo_frame_tag message do %>
<div class="message">
<p><%= message.body %></p>
<%= link_to "Edit", edit_message_path(message) %>
</div>
<% end %>
<%# app/views/messages/edit.html.erb %>
<%# Matching frame — also uses dom_id via the model %>
<%= turbo_frame_tag @message do %>
<%= render "form", message: @message %>
<% end %>
<%# For new records, dom_id generates "new_message" %>
<%# app/views/messages/new.html.erb %>
<%= turbo_frame_tag Message.new do %>
<%= render "form", message: @message %>
<% end %>
<%# You can also use dom_id with a prefix for multiple frames per model %>
<%= turbo_frame_tag dom_id(message, :comments) do %>
<%# Generates "comments_message_42" %>
<%= render message.comments %>
<% end %>Provide Meaningful Frame Loading States
Content placed inside a turbo_frame_tag with a src attribute displays as a placeholder until the frame's content loads from the server. An empty frame shows a blank gap that causes layout shift when content arrives. Providing skeleton placeholders or spinners inside the frame tag gives users immediate visual feedback and reserves the correct amount of vertical space.
Incorrect (empty frame that shows nothing during load):
<%# app/views/projects/show.html.erb %>
<%# Users see a blank gap until the frame loads — feels broken %>
<h1><%= @project.name %></h1>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project),
loading: :lazy %>
<%# Or with a generic "Loading..." that provides no spatial context %>
<%= turbo_frame_tag "project_activity",
src: project_activity_path(@project),
loading: :lazy do %>
Loading...
<% end %>Correct (skeleton placeholders that match loaded content dimensions):
<%# app/views/projects/show.html.erb %>
<h1><%= @project.name %></h1>
<%# Skeleton placeholder matches the shape of loaded comments %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project),
loading: :lazy do %>
<div class="comments-skeleton" aria-busy="true" aria-label="Loading comments">
<% 3.times do %>
<div class="skeleton-comment">
<div class="skeleton-avatar"></div>
<div class="skeleton-lines">
<div class="skeleton-line skeleton-line--wide"></div>
<div class="skeleton-line skeleton-line--medium"></div>
</div>
</div>
<% end %>
</div>
<% end %>
<%# For simpler sections, a contextual spinner works %>
<%= turbo_frame_tag "project_activity",
src: project_activity_path(@project),
loading: :lazy do %>
<div class="loading-placeholder" aria-busy="true">
<svg class="spinner" role="img" aria-label="Loading activity feed">
<!-- spinner SVG -->
</svg>
<span>Loading activity feed...</span>
</div>
<% end %>/* app/assets/stylesheets/skeletons.css */
.skeleton-comment {
display: flex;
gap: 12px;
padding: 16px 0;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--skeleton-bg, #e5e7eb);
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-line {
height: 12px;
border-radius: 4px;
background: var(--skeleton-bg, #e5e7eb);
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-line--wide { width: 80%; }
.skeleton-line--medium { width: 60%; margin-top: 8px; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}Defer Frame Loading Until Viewport Entry
Turbo Frames with loading: :lazy defer their src request until the frame enters the viewport. On pages with multiple independent sections (comments, activity feeds, related items), eager-loading all frames fires parallel requests on page load, increasing time to interactive and wasting bandwidth for content users may never scroll to.
Incorrect (eager-loading all frames on page load):
<%# app/views/projects/show.html.erb %>
<%# All 4 frames fire requests immediately on page load %>
<h1><%= @project.name %></h1>
<%= turbo_frame_tag "project_details",
src: project_details_path(@project) do %>
<p>Loading details...</p>
<% end %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project) do %>
<p>Loading comments...</p>
<% end %>
<%= turbo_frame_tag "project_activity",
src: project_activity_path(@project) do %>
<p>Loading activity...</p>
<% end %>
<%= turbo_frame_tag "related_projects",
src: related_projects_path(@project) do %>
<p>Loading related...</p>
<% end %>Correct (lazy-load below-fold content, eager-load above-fold):
<%# app/views/projects/show.html.erb %>
<h1><%= @project.name %></h1>
<%# Above the fold — load immediately (no loading: option needed) %>
<%= turbo_frame_tag "project_details",
src: project_details_path(@project) do %>
<p>Loading details...</p>
<% end %>
<%# Below the fold — lazy-load when user scrolls to them %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project),
loading: :lazy do %>
<div class="skeleton skeleton-comments">Loading comments...</div>
<% end %>
<%= turbo_frame_tag "project_activity",
src: project_activity_path(@project),
loading: :lazy do %>
<div class="skeleton skeleton-activity">Loading activity...</div>
<% end %>
<%= turbo_frame_tag "related_projects",
src: related_projects_path(@project),
loading: :lazy do %>
<div class="skeleton skeleton-cards">Loading related...</div>
<% end %>Promote Frame Navigation to Page Visits
By default, Turbo Frame navigations do not update the browser URL or push entries to the history stack. For interactions where the URL matters -- pagination, tab switching, filtering within a frame -- add data-turbo-action="advance" to make the frame navigation update the address bar. This preserves shareability, bookmarkability, and proper browser back/forward behavior.
Incorrect (frame navigation that doesn't update URL, breaking shareability):
<%# app/views/projects/show.html.erb %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project) do %>
<p>Loading comments...</p>
<% end %>
<%# app/views/comments/_pagination.html.erb %>
<%# Clicking page 3 loads comments but URL stays on /projects/1
— user can't share or bookmark page 3 of comments %>
<%= turbo_frame_tag "project_comments" do %>
<div class="comments">
<%= render @comments %>
</div>
<nav class="pagination">
<%= link_to "Previous", project_comments_path(@project, page: @page - 1) %>
<%= link_to "Next", project_comments_path(@project, page: @page + 1) %>
</nav>
<% end %>Correct (frame navigation promoted to page visits with URL updates):
<%# app/views/projects/show.html.erb %>
<%= turbo_frame_tag "project_comments",
src: project_comments_path(@project) do %>
<p>Loading comments...</p>
<% end %>
<%# app/views/comments/_pagination.html.erb %>
<%# data-turbo-action="advance" updates the URL when navigating %>
<%= turbo_frame_tag "project_comments" do %>
<div class="comments">
<%= render @comments %>
</div>
<nav class="pagination">
<%= link_to "Previous",
project_comments_path(@project, page: @page - 1),
data: { turbo_action: "advance" } %>
<%= link_to "Next",
project_comments_path(@project, page: @page + 1),
data: { turbo_action: "advance" } %>
</nav>
<% end %>
<%# For tab interfaces, promote tab selection to URL %>
<div class="tabs">
<%= link_to "Overview",
project_overview_path(@project),
data: { turbo_frame: "tab_content", turbo_action: "advance" } %>
<%= link_to "Members",
project_members_path(@project),
data: { turbo_frame: "tab_content", turbo_action: "advance" } %>
</div>
<%= turbo_frame_tag "tab_content",
src: project_overview_path(@project) do %>
<p>Loading...</p>
<% end %>Scope Navigation Within Frames
Links and forms inside a Turbo Frame are scoped to that frame by default -- the response replaces only the frame content, not the entire page. When a link inside a frame should navigate the full page (e.g., clicking a record title to view its detail page), you must explicitly set data-turbo-frame="_top". Conversely, use data-turbo-frame to target a different frame from outside it.
Incorrect (link inside frame unexpectedly replacing entire page or wrong frame):
<%# app/views/comments/_comment.html.erb %>
<%= turbo_frame_tag dom_id(comment) do %>
<div class="comment">
<p><%= comment.body %></p>
<%# This link tries to navigate inside the comment frame,
but the show page won't have a matching frame — shows nothing %>
<%= link_to comment.author.name, user_path(comment.author) %>
<%= link_to "Edit", edit_comment_path(comment) %>
</div>
<% end %>Correct (explicit target controls for frame-scoped vs page-level navigation):
<%# app/views/comments/_comment.html.erb %>
<%= turbo_frame_tag dom_id(comment) do %>
<div class="comment">
<p><%= comment.body %></p>
<%# Break out of the frame — navigate the full page %>
<%= link_to comment.author.name, user_path(comment.author),
data: { turbo_frame: "_top" } %>
<%# Stays within this frame — edit form replaces comment content %>
<%= link_to "Edit", edit_comment_path(comment) %>
</div>
<% end %>
<%# From outside the frame, target a specific frame %>
<%= link_to "Load latest comments",
project_comments_path(@project),
data: { turbo_frame: "project_comments" } %><%# app/views/comments/edit.html.erb %>
<%# The edit form must be wrapped in a matching frame %>
<%= turbo_frame_tag dom_id(@comment) do %>
<%= render "form", comment: @comment %>
<% end %>Use src for Dynamic Frame Content
Turbo Frames with a src attribute automatically fetch and render remote content, replacing the need for custom JavaScript fetch() + DOM manipulation. Changing the src attribute on a frame programmatically reloads it with new content. This pattern is ideal for tabs, paginated lists, and any section that loads content from a different endpoint.
Incorrect (JavaScript fetch + innerHTML to update a section):
<%# app/views/projects/show.html.erb %>
<div class="tabs">
<button onclick="loadTab('overview')">Overview</button>
<button onclick="loadTab('members')">Members</button>
<button onclick="loadTab('settings')">Settings</button>
</div>
<div id="tab-content">
<%# Content loaded via JavaScript %>
</div>
<script>
async function loadTab(tab) {
const response = await fetch(`/projects/<%= @project.id %>/${tab}`);
const html = await response.text();
document.getElementById("tab-content").innerHTML = html;
}
loadTab("overview");
</script>Correct (Turbo Frame with src for remote content loading):
<%# app/views/projects/show.html.erb %>
<div class="tabs">
<%= link_to "Overview",
project_overview_path(@project),
data: { turbo_frame: "tab_content" } %>
<%= link_to "Members",
project_members_path(@project),
data: { turbo_frame: "tab_content" } %>
<%= link_to "Settings",
project_settings_path(@project),
data: { turbo_frame: "tab_content" } %>
</div>
<%# Frame loads its src on page load, tabs swap the content %>
<%= turbo_frame_tag "tab_content",
src: project_overview_path(@project) do %>
<p>Loading...</p>
<% end %><%# app/views/projects/overview.html.erb %>
<%= turbo_frame_tag "tab_content" do %>
<div class="overview">
<h2>Project Overview</h2>
<p><%= @project.description %></p>
</div>
<% end %>Enable Morphing for Page Refreshes
By default, Turbo replaces the entire <body> on page refreshes, which destroys all DOM state including scroll position, focus, open modals, and Stimulus controller state. Enabling morphing via turbo_refreshes_with method: :morph makes Turbo diff the new HTML against the existing DOM and apply only the minimal set of changes. This is dramatically faster for pages with many elements and preserves client-side state that would otherwise be lost.
Incorrect (full page body replacement on every navigation):
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# No morph configuration — Turbo replaces the entire body %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
<%# Every broadcast refresh or Turbo visit replaces the full body,
destroying scroll position, open dropdowns, and form input state. %>Correct (turbo_refreshes_with in layout for morph-based refreshes):
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# Enable morphing for page refreshes with scroll preservation %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
<%# This adds two meta tags to the <head>:
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
Now broadcast refreshes diff the DOM instead of replacing it,
preserving scroll position, focus, and ephemeral UI state. %>Use refresh='morph' on Frames for Additive Content
When a Turbo Frame loads paginated or lazy-loaded content and a page refresh occurs (via broadcast or navigation), the default behavior replaces the entire frame content with whatever the server renders for page 1. This wipes out content the user loaded via "Load More" or infinite scroll. Setting refresh="morph" on the frame tells Turbo to morph the frame's contents during a page refresh, preserving the existing DOM while patching in any changes.
Incorrect (frame content wiped on page refresh, losing paginated results):
<%# app/views/projects/show.html.erb %>
<%= turbo_stream_from @project %>
<h1><%= @project.name %></h1>
<%# User clicks "Load More" three times, loading 60 tasks.
Another user updates a task, triggering broadcasts_refreshes.
The page morphs and this frame is replaced with the server response,
which only contains the first 20 tasks. 40 tasks disappear. %>
<%= turbo_frame_tag "tasks_list" do %>
<div id="tasks">
<%= render @tasks %>
</div>
<% if @tasks.next_page %>
<%= link_to "Load More",
project_tasks_path(@project, page: @tasks.next_page),
data: { turbo_frame: "tasks_list" } %>
<% end %>
<% end %>Correct (refresh="morph" on frame to preserve content across refreshes):
<%# app/views/projects/show.html.erb %>
<%= turbo_stream_from @project %>
<h1><%= @project.name %></h1>
<%# refresh="morph" tells Turbo to morph the frame contents instead of replacing.
Loaded pages are preserved; only changed elements are patched. %>
<%= turbo_frame_tag "tasks_list", refresh: "morph" do %>
<div id="tasks">
<%= render @tasks %>
</div>
<% if @tasks.next_page %>
<%= link_to "Load More",
project_tasks_path(@project, page: @tasks.next_page),
data: { turbo_frame: "tasks_list" } %>
<% end %>
<% end %>
<%# Now when a broadcast triggers a page refresh:
- Tasks already loaded via pagination stay in the DOM
- Any changed task content is morphed in place
- The "Load More" link state is preserved %>Mark Stateful Elements as Permanent
Even with morphing enabled, Turbo may still replace elements whose server-rendered HTML differs from their current client-side state (open dropdowns, playing videos, partially filled forms). Adding data-turbo-permanent to these elements tells Turbo to skip them entirely during morphing, preserving whatever state the user has built up. Each permanent element must have a unique id so Turbo can match it across renders.
Incorrect (dropdown closing every time a broadcast triggers page morph):
<%# app/views/shared/_notification_dropdown.html.erb %>
<%# No permanent marker — morph replaces this with server HTML (closed state) %>
<div id="notification_dropdown" class="dropdown">
<button data-controller="dropdown" data-action="click->dropdown#toggle">
Notifications (<%= current_user.unread_notifications_count %>)
</button>
<div class="dropdown-menu" data-dropdown-target="menu">
<%= render current_user.notifications.recent %>
</div>
</div>
<%# When another user creates a task and triggers a broadcast_refresh,
the morph replaces this element with the server-rendered closed state,
snapping the dropdown shut while the user is reading it. %>Correct (data-turbo-permanent with unique id on stateful elements):
<%# app/views/shared/_notification_dropdown.html.erb %>
<%# Permanent marker preserves client-side state across morphs %>
<div id="notification_dropdown" data-turbo-permanent class="dropdown">
<button data-controller="dropdown" data-action="click->dropdown#toggle">
Notifications (<%= current_user.unread_notifications_count %>)
</button>
<div class="dropdown-menu" data-dropdown-target="menu">
<%= render current_user.notifications.recent %>
</div>
</div>
<%# To update the count inside a permanent element, use a Turbo Stream
that targets a child element specifically: %>
<%= turbo_stream.update "notification_count" do %>
<%= current_user.unread_notifications_count %>
<% end %>
<%# In the dropdown, wrap the count in a targetable span: %>
<%# <span id="notification_count">5</span> %>Preserve Scroll Position During Morphing
When a broadcast triggers a page refresh (see `morph-enable-page-refresh` for enabling morphing), Turbo re-renders the page from the server. Without scroll preservation, the browser resets to the top of the page after every morph, which is disorienting for users reading content further down. Setting scroll: :preserve in the layout tells Turbo to maintain the current scroll position after morphing. Use scroll: :reset only on specific pages where returning to the top is the expected behavior (e.g., after navigating to a new resource).
Incorrect (page jumping to top after every broadcast morph):
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<%# Morph enabled but scroll preservation missing %>
<%= turbo_refreshes_with method: :morph %>
<%# Equivalent to: <meta name="turbo-refresh-scroll" content="reset"> (default) %>
</head>
<body>
<%= yield %>
</body>
</html>
<%# User scrolls to comment #47 in a long thread.
Another user posts a comment, triggering broadcasts_refreshes.
The page morphs and jumps back to the top. User loses their place. %>Correct (scroll: :preserve in layout, reset on specific pages):
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<%# Preserve scroll globally — most pages should keep position %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
</head>
<body>
<%= yield %>
</body>
</html>
<%# For specific pages that SHOULD reset scroll (e.g., navigating to a new record),
override in the view using content_for or a page-specific meta tag: %>
<%# app/views/projects/show.html.erb %>
<% content_for :head do %>
<meta name="turbo-refresh-scroll" content="reset">
<% end %>
<%# The layout should yield :head inside <head> for per-page overrides: %>
<%# <head>
turbo_refreshes_with method: :morph, scroll: :preserve
yield :head
</head> %>Handle Stimulus Controller Reconnection After Morph
When Turbo morphs the DOM, it patches elements in place rather than removing and re-inserting them. This means Stimulus controllers attached to morphed elements may not receive disconnect/connect lifecycle callbacks, leaving stale state (expired timers, orphaned event listeners, outdated data).
Decision tree for morph handling: 1. If your state is in Stimulus values (data attributes): use valueChanged callbacks — Stimulus detects attribute changes from morphing automatically. 2. If your state is NOT in values (timers, third-party library instances, manual event listeners): listen to turbo:morph-element to detect when your element has been patched and re-initialize.
Incorrect (Stimulus controller state lost after morph):
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { deadline: String };
connect() {
this.startCountdown();
}
disconnect() {
clearInterval(this.timer);
}
startCountdown() {
this.timer = setInterval(() => {
const remaining = new Date(this.deadlineValue) - new Date();
this.element.textContent = this.formatTime(remaining);
}, 1000);
}
// BUG: when morph updates the deadline value attribute,
// the controller keeps counting down to the OLD deadline
// because connect/disconnect are never called.
formatTime(ms) {
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / 60000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
}Correct (valueChanged callback for value-based state, turbo:morph-element for non-value state):
Approach 1 — valueChanged callback (preferred when state is in values):
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { deadline: String };
connect() {
this.startCountdown();
}
disconnect() {
clearInterval(this.timer);
}
// Stimulus fires this when the data-countdown-deadline-value attribute
// changes — including when Turbo morphs new attribute values onto the element
deadlineValueChanged() {
clearInterval(this.timer);
this.startCountdown();
}
startCountdown() {
this.timer = setInterval(() => {
const remaining = new Date(this.deadlineValue) - new Date();
this.element.textContent = this.formatTime(remaining);
}, 1000);
}
formatTime(ms) {
const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / 60000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
}Approach 2 — turbo:morph-element (for state not captured in values):
// app/javascript/controllers/chart_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { endpoint: String };
connect() {
// Third-party library instance — not stored in Stimulus values
this.chart = new ChartLibrary(this.element, { endpoint: this.endpointValue });
this.element.addEventListener("turbo:morph-element", this.handleMorph);
}
disconnect() {
this.chart.destroy();
this.element.removeEventListener("turbo:morph-element", this.handleMorph);
}
handleMorph = () => {
// Re-initialize the chart with new DOM state after morph
this.chart.destroy();
this.chart = new ChartLibrary(this.element, { endpoint: this.endpointValue });
};
}Choose Morphing Over Complex Stream Orchestration
When a single user action affects multiple page sections (sidebar counts, main content, header badges, activity feeds), orchestrating individual Turbo Stream tags for each target becomes brittle and hard to maintain. See also `bcast-refresh-over-replace` for the model declaration side. Each stream tag requires a matching DOM ID, the correct action, and the right partial with the right locals. A broadcast refresh with morphing re-renders the entire page server-side and patches only the differences, achieving the same result with zero stream orchestration.
Incorrect (5+ turbo_stream tags targeting different page sections):
<%# app/views/tasks/update.turbo_stream.erb %>
<%# Update the task in the list %>
<%= turbo_stream.replace dom_id(@task), partial: "tasks/task", locals: { task: @task } %>
<%# Update the project progress bar %>
<%= turbo_stream.replace "project_progress" do %>
<div id="project_progress">
<%= render "projects/progress_bar", project: @task.project %>
</div>
<% end %>
<%# Update the sidebar task counts %>
<%= turbo_stream.update "open_tasks_count", @task.project.tasks.open.count %>
<%= turbo_stream.update "completed_tasks_count", @task.project.tasks.completed.count %>
<%# Update the activity feed %>
<%= turbo_stream.prepend "activity_feed" do %>
<%= render "activities/activity", activity: @task.activities.last %>
<% end %>
<%# Update the assignee's workload badge %>
<%= turbo_stream.replace "assignee_workload_#{@task.assignee_id}" do %>
<%= render "users/workload_badge", user: @task.assignee %>
<% end %>
<%# If any DOM ID is wrong, that section silently fails to update. %>Correct (broadcasts_refreshes with morphing updates everything at once):
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
belongs_to :assignee, class_name: "User"
broadcasts_refreshes_to :project
end# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def update
@task = @project.tasks.find(params[:id])
if @task.update(task_params)
# The acting user gets a standard redirect (Turbo Drive navigates).
# Other viewers get the update via broadcasts_refreshes on the model.
redirect_to @project
else
render :edit, status: :unprocessable_entity
end
end
end<%# app/views/projects/show.html.erb %>
<%# No stream templates needed — the normal view IS the template.
All sections (progress bar, counts, feed, badges) render from
the same server response and morph into place automatically. %>
<%= turbo_stream_from @project %>
<%= render "projects/progress_bar", project: @project %>
<%= render "sidebar/task_counts", project: @project %>
<%= render @project.tasks %>
<%= render "activities/feed", activities: @project.activities.recent %>Batch Multiple Stream Updates into Single Responses
When a single user action requires multiple DOM updates, sending separate HTTP requests or individual broadcasts for each change creates excessive network overhead and sequential DOM mutations. Combining all stream actions into a single response lets the browser batch-process DOM updates in one paint cycle, dramatically reducing total execution time and visual jank.
Incorrect (separate HTTP requests or broadcasts for each DOM change):
# app/models/comment.rb
class Comment < ApplicationRecord
after_create_commit :broadcast_updates
private
def broadcast_updates
broadcast_append_to "comments", target: "comments"
# Separate broadcast for count
broadcast_replace_to "comments", target: "comments_count",
partial: "comments/count", locals: { count: Comment.count }
# Another separate broadcast for the sidebar
broadcast_replace_to "comments", target: "recent_activity",
partial: "shared/recent_activity"
end
endCorrect (single turbo_stream.erb with multiple actions):
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@comment = @project.comments.build(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @project }
end
end
end
end<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments" do %>
<%= render @comment %>
<% end %>
<%= turbo_stream.update "comments_count" do %>
<%= @project.comments.count %> comments
<% end %>
<%= turbo_stream.replace "recent_activity" do %>
<%= render "shared/recent_activity", project: @project %>
<% end %>
<%= turbo_stream.remove "no_comments_placeholder" %>Cache Turbo Frame Responses with Fragment Caching
Turbo Frames trigger separate HTTP requests for lazy-loaded or navigated content, which can multiply database queries across a page. Wrapping frame partials in Rails fragment caching with proper cache keys prevents redundant database hits and template rendering on subsequent requests. This is especially impactful for frames that display data shared across users or change infrequently.
Incorrect (frame endpoint hitting database on every request):
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
def team_members
@project = Project.find(params[:id])
@members = @project.members.includes(:avatar_attachment).order(:name)
render partial: "projects/team_members", locals: { members: @members }
end
end<%# app/views/projects/_team_members.html.erb %>
<turbo-frame id="team_members">
<% members.each do |member| %>
<div class="member-card">
<%= image_tag member.avatar, class: "avatar" %>
<span><%= member.name %></span>
<span class="role"><%= member.role %></span>
</div>
<% end %>
</turbo-frame>Correct (cache block wrapping frame content with proper cache keys):
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
def team_members
@project = Project.find(params[:id])
@members = @project.members.includes(:avatar_attachment).order(:name)
if stale?(etag: @project.members_cache_key)
render partial: "projects/team_members", locals: { project: @project, members: @members }
end
end
end<%# app/views/projects/_team_members.html.erb %>
<turbo-frame id="team_members">
<% cache [project, "team_members", project.members.maximum(:updated_at)] do %>
<% members.each do |member| %>
<div class="member-card">
<%= image_tag member.avatar, class: "avatar" %>
<span><%= member.name %></span>
<span class="role"><%= member.role %></span>
</div>
<% end %>
<% end %>
</turbo-frame>Clean Up Subscriptions and Event Listeners
Stimulus controllers connect and disconnect as elements enter and leave the DOM during Turbo navigations. If connect() registers event listeners, creates timers, or opens ActionCable subscriptions without corresponding cleanup in disconnect(), those resources accumulate with each page visit. Over a session, this causes growing memory consumption, duplicate event handling, and degraded performance.
Incorrect (addEventListener in connect() without removeEventListener in disconnect()):
// app/javascript/controllers/notification_controller.js
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
connect() {
// Leak: new subscription created on every connect, never removed
createConsumer().subscriptions.create("NotificationChannel", {
received: (data) => this.showNotification(data)
})
// Leak: anonymous function cannot be removed
window.addEventListener("resize", () => {
this.adjustLayout()
})
// Leak: interval never cleared
setInterval(() => this.pollStatus(), 5000)
}
showNotification(data) {
this.element.insertAdjacentHTML("beforeend",
`<div class="notification">${data.message}</div>`)
}
adjustLayout() {
this.element.style.width = `${window.innerWidth - 40}px`
}
pollStatus() {
fetch("/notifications/unread_count")
.then(r => r.json())
.then(data => this.updateBadge(data.count))
}
}Correct (proper cleanup in disconnect() for all manually added listeners):
// app/javascript/controllers/notification_controller.js
import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
connect() {
this.cable = createConsumer()
this.subscription = this.cable.subscriptions.create("NotificationChannel", {
received: (data) => this.showNotification(data)
})
this.resizeHandler = this.adjustLayout.bind(this)
window.addEventListener("resize", this.resizeHandler)
this.pollTimer = setInterval(() => this.pollStatus(), 5000)
}
disconnect() {
this.subscription?.unsubscribe()
this.cable?.disconnect()
window.removeEventListener("resize", this.resizeHandler)
clearInterval(this.pollTimer)
}
showNotification(data) {
this.element.insertAdjacentHTML("beforeend",
`<div class="notification">${data.message}</div>`)
}
adjustLayout() {
this.element.style.width = `${window.innerWidth - 40}px`
}
pollStatus() {
fetch("/notifications/unread_count")
.then(r => r.json())
.then(data => this.updateBadge(data.count))
}
}Implement Optimistic UI Updates Before Server Confirmation
Users perceive applications as slow when they must wait for a full server round-trip before seeing any visual feedback. Optimistic UI immediately reflects the expected outcome on the client, then lets Turbo Morph confirm or correct the state when the server responds. This dramatically improves perceived performance on actions with predictable outcomes like sending messages or toggling favorites.
Incorrect (waiting for full server round-trip before showing any UI change):
<!-- app/views/messages/_form.html.erb -->
<%= form_with model: [project, Message.new], data: { turbo_stream: true } do |f| %>
<%= f.text_area :body %>
<%= f.submit "Send", class: "btn" %>
<% end %>
<!-- User clicks Send, sees nothing for 260ms until server responds -->
<!-- No visual feedback during the wait -->Correct (Stimulus controller adds optimistic element, server morph validates):
<!-- app/views/messages/_form.html.erb -->
<%= form_with model: [project, Message.new],
data: {
turbo_stream: true,
controller: "optimistic-message",
action: "turbo:submit-start->optimistic-message#add turbo:submit-end->optimistic-message#resolve"
} do |f| %>
<%= f.text_area :body, data: { optimistic_message_target: "input" } %>
<%= f.submit "Send", class: "btn" %>
<% end %>// app/javascript/controllers/optimistic_message_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
add(event) {
const body = this.inputTarget.value
if (!body.trim()) return
const messages = document.getElementById("messages")
const optimistic = document.createElement("div")
optimistic.classList.add("message", "message--optimistic")
optimistic.textContent = body
// Track this specific submission with a unique ID
const submissionId = `optimistic-${Date.now()}`
optimistic.id = submissionId
this.currentOptimisticId = submissionId
messages.appendChild(optimistic)
this.inputTarget.value = ""
}
resolve(event) {
const optimistic = document.getElementById(this.currentOptimisticId)
if (!optimistic) return
if (event.detail.success) {
// Server morph will replace with the real message
optimistic.remove()
} else {
// Submission failed — show error and restore input
optimistic.classList.replace("message--optimistic", "message--failed")
optimistic.textContent += " (failed to send)"
this.inputTarget.value = optimistic.textContent.replace(" (failed to send)", "")
}
}
}Disable Prefetch on Expensive Endpoints
Turbo Drive 8 prefetches links on hover by default (see `drive-prefetch-links` for the general pattern), which improves perceived navigation speed for lightweight pages. However, links to pages that trigger heavy database queries, report generation, or complex rendering will waste server resources when users merely hover without clicking. Selectively disabling prefetch on expensive endpoints prevents unnecessary load while keeping the optimization active where it helps.
Incorrect (every link prefetched, including admin dashboards and report generators):
<%# app/views/layouts/application.html.erb %>
<head>
<%# Default Turbo Drive prefetch is enabled for all links %>
<meta name="turbo-prefetch" content="true">
</head>
<%# app/views/projects/show.html.erb %>
<nav>
<%= link_to "Dashboard", project_path(@project) %>
<%= link_to "Analytics", project_analytics_path(@project) %>
<%= link_to "Export CSV", project_export_path(@project, format: :csv) %>
<%= link_to "Audit Log", project_audit_log_path(@project) %>
<%# All links prefetch on hover — analytics and export hit the DB for nothing %>
</nav>Correct (data-turbo-prefetch="false" on heavy endpoints, default prefetch on lightweight pages):
<%# app/views/projects/show.html.erb %>
<nav>
<%= link_to "Dashboard", project_path(@project) %>
<%= link_to "Analytics", project_analytics_path(@project),
data: { turbo_prefetch: false } %>
<%= link_to "Export CSV", project_export_path(@project, format: :csv),
data: { turbo_prefetch: false } %>
<%= link_to "Audit Log", project_audit_log_path(@project),
data: { turbo_prefetch: false } %>
</nav>
<%# Or disable for an entire section with expensive links %>
<div data-turbo-prefetch="false">
<h3>Admin Tools</h3>
<%= link_to "User Report", admin_users_report_path %>
<%= link_to "Revenue Dashboard", admin_revenue_path %>
<%= link_to "System Health", admin_health_path %>
</div>Use Declarative Action Descriptors Over addEventListener
Stimulus action descriptors (data-action="event->controller#method") declaratively bind events to controller methods in HTML. Stimulus manages listener lifecycle automatically — adding on connect and removing on disconnect. Manual addEventListener calls in connect() require matching removeEventListener in disconnect() with bound function references, which is a common source of memory leaks in Turbo-navigated applications.
Incorrect (manual addEventListener in connect, easy to forget cleanup):
// app/javascript/controllers/keyboard_shortcut_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
// BAD: manual listener requires manual cleanup
this.handleKeydown = this.handleKeydown.bind(this)
document.addEventListener("keydown", this.handleKeydown)
this.element.querySelector(".search-input")
.addEventListener("input", (e) => this.search(e))
this.element.querySelector(".clear-btn")
.addEventListener("click", () => this.clear())
}
disconnect() {
// Easy to forget, or to miss one of the listeners
document.removeEventListener("keydown", this.handleKeydown)
// The anonymous functions above can never be removed — memory leak
}
}Correct (data-action descriptors handle lifecycle automatically):
<div data-controller="keyboard-shortcut"
data-action="keydown@document->keyboard-shortcut#handleKeydown">
<input type="search"
class="search-input"
data-action="input->keyboard-shortcut#search">
<button class="clear-btn"
data-action="click->keyboard-shortcut#clear">
Clear
</button>
</div>// app/javascript/controllers/keyboard_shortcut_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// No connect/disconnect needed — Stimulus manages all listeners
handleKeydown(event) {
if (event.key === "/" && !event.metaKey) {
event.preventDefault()
this.element.querySelector(".search-input").focus()
}
}
search(event) {
// Turbo Frame handles the actual filtering
clearTimeout(this.debounceTimer)
this.debounceTimer = setTimeout(() => {
event.target.form.requestSubmit()
}, 300)
}
clear() {
this.element.querySelector(".search-input").value = ""
this.search({ target: this.element.querySelector(".search-input") })
}
}When NOT to use this pattern:
- For events on elements created dynamically after
connect()— use event delegation on a parent target instead - For third-party library callbacks that don't fire DOM events — manual wiring in
connect()with cleanup indisconnect()is required
Reference: Stimulus Reference — Actions
Use Outlets for Cross-Controller Communication
When Stimulus controllers need to coordinate — a search controller filtering a results list, a modal controller closing from a form controller — the temptation is to use document.querySelector or dispatch custom DOM events. Outlets provide a declarative, testable way to reference other controller instances directly. Stimulus manages the lifecycle automatically: outlet callbacks fire when connected/disconnected, preventing stale references.
Incorrect (querying the DOM or dispatching global events to coordinate controllers):
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
filter() {
const query = this.element.querySelector("input").value
// BAD: brittle DOM query to find the results controller
const resultsEl = document.querySelector("[data-controller='results']")
if (resultsEl) {
// BAD: accessing another controller's internals
const resultsController = this.application.getControllerForElementAndIdentifier(
resultsEl, "results"
)
resultsController.updateFilter(query)
}
}
}Correct (outlets for declarative cross-controller references):
<%# app/views/projects/index.html.erb %>
<div data-controller="search"
data-search-results-outlet="#project-results">
<%= form_with url: projects_path, method: :get,
data: { action: "input->search#filter" } do |f| %>
<%= f.search_field :q, value: params[:q] %>
<% end %>
</div>
<div id="project-results"
data-controller="results"
data-results-url-value="<%= projects_path %>">
<%= render @projects %>
</div>// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["results"]
filter() {
const query = this.element.querySelector("input").value
// Outlet reference is managed by Stimulus — always current
this.resultsOutlet.updateFilter(query)
}
// Optional: react when the results controller connects/disconnects
resultsOutletConnected(outlet, element) {
// Results are ready — enable the search input
this.element.querySelector("input").disabled = false
}
}When NOT to use this pattern:
- For one-to-many communication (one controller to many listeners) — use Stimulus
dispatchwith custom events instead - When controllers are on different pages — outlets only work within the same DOM tree
Reference: Stimulus Reference — Outlets
Keep Stimulus Controllers Small and Reusable
A Stimulus controller should do one thing well: toggle visibility, copy to clipboard, count characters, manage a dropdown. When a controller grows beyond 50-80 lines, it likely handles multiple responsibilities and should be split. Small controllers can be composed on the same element or nested elements using multiple data-controller declarations, mirroring how CSS classes compose styling.
Incorrect (monolithic controller handling form validation, submission, and UI feedback):
// app/javascript/controllers/comment_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "counter", "submit", "preview"]
static values = { maxLength: { type: Number, default: 500 } }
validate() {
const length = this.inputTarget.value.length
this.counterTarget.textContent = `${this.maxLengthValue - length} remaining`
this.counterTarget.classList.toggle("text-red-600", length > this.maxLengthValue)
this.submitTarget.disabled = length === 0 || length > this.maxLengthValue
}
preview() {
this.previewTarget.innerHTML = this.inputTarget.value
this.previewTarget.classList.remove("hidden")
}
submit(event) {
if (this.inputTarget.value.length > this.maxLengthValue) {
event.preventDefault()
}
this.submitTarget.textContent = "Sending..."
this.submitTarget.disabled = true
}
}Correct (composed small controllers, each with a single responsibility):
<%= form_with model: [@project, Comment.new],
data: { controller: "auto-submit", action: "turbo:submit-start->auto-submit#disable" } do |f| %>
<%= f.text_area :body,
data: {
controller: "character-count",
character_count_max_value: 500,
action: "input->character-count#update"
} %>
<span data-character-count-target="counter">500 remaining</span>
<%= f.submit "Post Comment" %>
<% end %>// app/javascript/controllers/character_count_controller.js
// Reusable on ANY text input across the app
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["counter"]
static values = { max: { type: Number, default: 280 } }
update() {
const remaining = this.maxValue - this.element.value.length
this.counterTarget.textContent = `${remaining} remaining`
this.counterTarget.classList.toggle("text-red-600", remaining < 0)
}
}// app/javascript/controllers/auto_submit_controller.js
// Reusable on ANY form that needs submit-state management
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
disable() {
const button = this.element.querySelector("[type='submit']")
button.disabled = true
button.dataset.originalText = button.textContent
button.textContent = "Sending..."
}
}Reference: Stimulus Handbook — Origin
Use Values API for Reactive Controller State
Stimulus Values let you declare typed state on a controller element via data-*-value attributes. When a value changes — whether from JavaScript or from Turbo morphing the DOM — Stimulus fires a {name}ValueChanged callback automatically. This replaces manual getAttribute calls, keeps state in HTML (the single source of truth), and ensures controllers re-initialize correctly after morph.
Incorrect (reading state from DOM attributes manually, missing morph updates):
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
// BAD: manual attribute parsing — no type safety, no change detection
this.deadline = new Date(this.element.dataset.deadline)
this.warningThreshold = parseInt(this.element.dataset.warningMinutes) || 5
this.start()
}
start() {
this.timer = setInterval(() => {
const remaining = this.deadline - new Date()
this.element.textContent = this.format(remaining)
// BAD: if Turbo morph changes data-deadline, this still uses the old value
}, 1000)
}
}Correct (Values API with typed defaults and change callbacks):
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
deadline: String,
warningMinutes: { type: Number, default: 5 }
}
connect() {
this.start()
}
disconnect() {
clearInterval(this.timer)
}
// Fires automatically when data-countdown-deadline-value changes
// — including after Turbo morph updates the attribute
deadlineValueChanged() {
clearInterval(this.timer)
this.start()
}
start() {
this.timer = setInterval(() => {
const remaining = new Date(this.deadlineValue) - new Date()
this.element.textContent = this.format(remaining)
this.element.classList.toggle(
"text-red-600",
remaining < this.warningMinutesValue * 60000
)
}, 1000)
}
format(ms) {
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor(ms / 1000) % 60
return `${minutes}:${seconds.toString().padStart(2, "0")}`
}
}<%# Values are set via data attributes — HTML is the source of truth %>
<span data-controller="countdown"
data-countdown-deadline-value="<%= @auction.ends_at.iso8601 %>"
data-countdown-warning-minutes-value="10">
</span>Reference: Stimulus Reference — Values
Choose the Right Stream Action for DOM Mutations
Turbo Streams provides nine actions, each suited to a specific DOM mutation pattern. Using replace when update would suffice destroys the outer element and its event listeners, causing flickering and breaking attached Stimulus controllers. Conversely, using update when replace is needed leaves stale attributes on the wrapper element. Choosing the correct action minimizes DOM churn and preserves client-side state.
Incorrect (using replace when update would preserve event listeners):
<%# Replaces the entire element, destroying attached Stimulus controllers and event listeners %>
<%= turbo_stream.replace "message_#{@message.id}" do %>
<div id="<%= dom_id(@message) %>"
data-controller="clipboard"
data-clipboard-copied-class="text-green-500">
<p><%= @message.body %></p>
<span class="status"><%= @message.edited? ? "edited" : "" %></span>
</div>
<% end %>Correct (update for inner HTML changes, replace for full element swap):
<%# UPDATE: changes inner HTML only, preserving the outer element and its controllers %>
<%= turbo_stream.update dom_id(@message) do %>
<p><%= @message.body %></p>
<span class="status"><%= @message.edited? ? "edited" : "" %></span>
<% end %>
<%# REPLACE: use when the outer element attributes themselves change %>
<%= turbo_stream.replace dom_id(@message) do %>
<div id="<%= dom_id(@message) %>"
data-controller="clipboard"
class="<%= @message.pinned? ? 'bg-yellow-50' : 'bg-white' %>">
<p><%= @message.body %></p>
</div>
<% end %>
<%# Quick reference for all 9 actions:
append - add to end of container (new items in a list)
prepend - add to start of container (newest-first feeds)
replace - swap entire element including wrapper (attribute changes)
update - swap inner HTML only (content changes, preserves controllers)
remove - delete element (after destroy)
before - insert before target (insert above a specific item)
after - insert after target (insert below a specific item)
morph - smart diff/patch of element (minimal DOM changes)
refresh - trigger full page morph (broadcast-driven updates)
%>Register Custom Stream Actions for Complex DOM Updates
The built-in nine stream actions cover most DOM mutations, but application-specific behaviors like showing a toast notification, updating a chart, or triggering a CSS animation require custom logic. Registering a custom action with Turbo.StreamActions keeps this logic declarative and delivered via the same stream pipeline, eliminating ad-hoc JavaScript event listeners that bypass Turbo's lifecycle.
Incorrect (JavaScript event listeners doing DOM manipulation after stream delivery):
// app/javascript/application.js
// BAD: manual listener that duplicates stream delivery logic
document.addEventListener("turbo:before-stream-render", (event) => {
const stream = event.target;
if (stream.querySelector("template").content.textContent.includes("flash:")) {
event.preventDefault();
const message = stream.querySelector("template").content.textContent.replace("flash:", "");
showToast(message);
}
});
function showToast(message) {
const toast = document.createElement("div");
toast.className = "toast toast-success";
toast.textContent = message;
document.getElementById("toast-container").appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}Correct (custom StreamAction registered with Turbo.StreamActions):
// app/javascript/custom_stream_actions.js
import { Turbo } from "@hotwired/turbo-rails";
// Register a custom "toast" action
Turbo.StreamActions.toast = function () {
const message = this.templateContent.textContent.trim();
const level = this.getAttribute("level") || "info";
const toast = document.createElement("div");
toast.className = `toast toast-${level}`;
toast.textContent = message;
document.getElementById("toast-container").appendChild(toast);
setTimeout(() => toast.remove(), 3000);
};<%# app/views/messages/create.turbo_stream.erb %>
<%= turbo_stream.append "messages", partial: "messages/message", locals: { message: @message } %>
<%# Trigger the custom toast action %>
<turbo-stream action="toast" level="success">
<template>Message sent successfully</template>
</turbo-stream>Deliver Streams via HTTP for Form Responses
Turbo Streams can be delivered over HTTP as the response to a form submission or over WebSocket via Action Cable. For the user who submitted the form, an HTTP stream response is simpler, faster, and does not require a persistent WebSocket connection. Reserve WebSocket broadcasts for pushing updates to other users who are viewing the same resource. Mixing these concerns leads to duplicate updates or unnecessary infrastructure.
Incorrect (broadcasting over WebSocket for the submitting user's own update):
class MessagesController < ApplicationController
def create
@message = @conversation.messages.build(message_params)
@message.user = current_user
if @message.save
# BAD: the submitting user will see their own message twice
# (once from broadcast, once from page reload/redirect)
Turbo::StreamsChannel.broadcast_append_to(
@conversation,
target: "messages",
partial: "messages/message",
locals: { message: @message }
)
redirect_to @conversation
end
end
endCorrect (HTTP stream for the actor, broadcast for other users):
class MessagesController < ApplicationController
def create
@message = @conversation.messages.build(message_params)
@message.user = current_user
if @message.save
respond_to do |format|
# Actor gets the stream response directly via HTTP
format.turbo_stream
format.html { redirect_to @conversation }
end
# Other users get the update via WebSocket broadcast
@message.broadcast_append_later_to(
@conversation,
target: "messages",
partial: "messages/message"
)
else
render :new, status: :unprocessable_entity
end
end
endUse targets for Multi-Element Updates
When a single action must update several elements sharing a common pattern (badges, counters, status indicators), emitting one stream tag per element creates bloated responses and forces you to track every DOM ID. The targets attribute (plural) accepts a CSS selector and applies the action to all matching elements in one declaration, reducing response size and keeping the stream template maintainable.
Incorrect (multiple turbo_stream tags each targeting one element):
<%# Updates unread count in 4 different places on the page %>
<%= turbo_stream.update "header_unread_count" do %>
<span><%= current_user.unread_messages_count %></span>
<% end %>
<%= turbo_stream.update "sidebar_unread_count" do %>
<span><%= current_user.unread_messages_count %></span>
<% end %>
<%= turbo_stream.update "mobile_nav_unread_count" do %>
<span><%= current_user.unread_messages_count %></span>
<% end %>
<%= turbo_stream.update "tab_unread_count" do %>
<span><%= current_user.unread_messages_count %></span>
<% end %>Correct (single turbo_stream with targets CSS selector):
<%# One stream tag updates every element matching the selector %>
<%= turbo_stream.update_all ".unread-messages-count" do %>
<span><%= current_user.unread_messages_count %></span>
<% end %>
<%# In the views, mark all badge locations with the shared class and unique IDs: %>
<%# header: <span id="header_unread_count" class="unread-messages-count">3</span> %>
<%# sidebar: <span id="sidebar_unread_count" class="unread-messages-count">3</span> %>
<%# mobile: <span id="mobile_nav_unread_count" class="unread-messages-count">3</span> %>
<%# For raw Turbo Stream HTML (e.g., in a broadcast job): %>
<%# <turbo-stream action="update" targets=".unread-messages-count"> %>
<%# <template><span>5</span></template> %>
<%# </turbo-stream> %>Always Provide HTML Fallback for Streams
Turbo Stream responses only work when Turbo is loaded and the request includes the text/vnd.turbo-stream.html Accept header. If JavaScript fails to load, the user is on a degraded connection, or Turbo encounters an error, the form submission will receive no usable response. A respond_to block with both turbo_stream and html formats ensures every user gets a working experience regardless of client capabilities.
Incorrect (only turbo_stream format, no fallback):
class CommentsController < ApplicationController
def create
@comment = @project.comments.build(comment_params)
@comment.save
respond_to do |format|
format.turbo_stream
end
end
endCorrect (turbo_stream with HTML redirect fallback):
class CommentsController < ApplicationController
def create
@comment = @project.comments.build(comment_params)
if @comment.save
respond_to do |format|
format.turbo_stream # renders create.turbo_stream.erb
format.html { redirect_to @project, notice: "Comment added." }
end
else
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"new_comment",
partial: "comments/form",
locals: { comment: @comment }
)
end
format.html { render "projects/show", status: :unprocessable_entity }
end
end
end
endRelated skills
How it compares
Pick rails-hotwire over generic Rails skills when reviewing or generating Turbo Frames, Streams, broadcasts, and Stimulus patterns with documented dot-skills rules.
FAQ
How many rules does rails-hotwire include?
rails-hotwire includes 53 rules organized across 9 categories in pproenca/dot-skills. Categories range from CRITICAL Navigation and Turbo Frames through MEDIUM Testing Hotwire, each with prioritized reference files.
What Hotwire components does rails-hotwire cover?
rails-hotwire covers Turbo Drive navigation, Turbo Frames partial updates, Turbo Streams DOM mutations, ActionCable broadcasting, Turbo 8 morphing, and Stimulus controller patterns. The skill complements rails-dev, rails-testing, and ruby-refactor dot-skills.