| name | Turbo & Hotwire Patterns |
| description | Complete guide to Hotwire implementation including Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus controllers in Rails applications. Use when: (1) Implementing partial page updates, (2) Adding real-time features, (3) Creating Turbo Frames and Streams, (4) Writing Stimulus controllers, (5) Debugging Turbo-related issues. Trigger keywords: Turbo, Stimulus, Hotwire, real-time, SPA, live updates, ActionCable, broadcasts, turbo_stream, turbo_frame |
| version | 1.1.0 |
Turbo & Hotwire Patterns
Hotwire Decision Tree
What do I need?
│
├─ Full page navigation without reload?
│ └─ Turbo Drive (automatic, no config needed)
│
├─ Update part of page on interaction?
│ └─ Turbo Frames
│ └─ Wrap section in turbo_frame_tag
│
├─ Real-time updates from server?
│ └─ Turbo Streams + ActionCable
│ └─ Model broadcasts + turbo_stream_from
│
├─ Multiple DOM changes on form submit?
│ └─ Turbo Stream responses
│ └─ respond_to format.turbo_stream
│
├─ JavaScript behavior (click, input, etc)?
│ └─ Stimulus controller
│ └─ data-controller + data-action
│
└─ Communication between controllers?
└─ Stimulus Outlets
└─ static outlets + data-*-outlet
NEVER Do This
NEVER forget matching frame IDs:
<%# WRONG - IDs don't match, frame won't update %>
<%= turbo_frame_tag "tasks" do %>
<%= link_to "Edit", edit_task_path(@task) %>
<% end %>
<%# edit.html.erb - different ID %>
<%= turbo_frame_tag "task_edit" do %>
<%= render "form" %>
<% end %>
<%# RIGHT - IDs match %>
<%= turbo_frame_tag dom_id(@task) do %>
<%= link_to "Edit", edit_task_path(@task) %>
<% end %>
<%# edit.html.erb - same ID %>
<%= turbo_frame_tag dom_id(@task) do %>
<%= render "form" %>
<% end %>
NEVER return HTML for form errors (return 422):
format.turbo_stream {
render turbo_stream: turbo_stream.replace("form", partial: "form")
}
format.turbo_stream {
render turbo_stream: turbo_stream.replace("form", partial: "form"),
status: :unprocessable_entity
}
NEVER use target without checking existence:
search() {
this.resultsTarget.innerHTML = html
}
search() {
if (this.hasResultsTarget) {
this.resultsTarget.innerHTML = html
}
}
NEVER forget cleanup in disconnect:
connect() {
this.timer = setInterval(() => this.refresh(), 1000)
}
connect() {
this.timer = setInterval(() => this.refresh(), 1000)
}
disconnect() {
clearInterval(this.timer)
}
NEVER skip ARIA for dynamic content:
<%# WRONG - screen readers miss updates %>
<div id="tasks">
<%= render @tasks %>
</div>
<%# RIGHT - announce updates %>
<div id="tasks" aria-live="polite">
<%= render @tasks %>
</div>
Hotwire Stack Overview
Hotwire
├── Turbo
│ ├── Turbo Drive — Full page navigation without reload
│ ├── Turbo Frames — Partial page updates
│ └── Turbo Streams — Real-time updates over WebSocket/HTTP
│
└── Stimulus — Lightweight JavaScript controllers
External References:
Turbo Drive
Automatically converts links/forms to AJAX. Disable when needed:
<%# Skip Turbo Drive for this link %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>
<%# Skip for form %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
Turbo Frames Quick Reference
| Pattern | Usage |
|---|
| Basic frame | turbo_frame_tag "id" |
| Model frame | turbo_frame_tag dom_id(@task) |
| Target other | data: { turbo_frame: "other_id" } |
| Break out | data: { turbo_frame: "_top" } |
| Lazy load | src: path, loading: :lazy |
<%# Lazy loading example %>
<%= turbo_frame_tag "comments",
src: task_comments_path(@task),
loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
Turbo Streams Quick Reference
| Action | Result |
|---|
append | Add to end of container |
prepend | Add to start of container |
replace | Replace entire element |
update | Replace element's contents |
remove | Delete element |
before/after | Insert adjacent |
<%# Stream response %>
<%= turbo_stream.prepend "tasks" do %>
<%= render @task %>
<% end %>
<%= turbo_stream.remove dom_id(@deleted_task) %>
Model Broadcasts
class Task < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "tasks" }
after_update_commit -> { broadcast_replace_to "tasks" }
after_destroy_commit -> { broadcast_remove_to "tasks" }
end
<%# Subscribe in view %>
<%= turbo_stream_from "tasks" %>
<div id="tasks"><%= render @tasks %></div>
Stimulus Quick Reference
| Feature | Declaration | HTML |
|---|
| Targets | static targets = ["input"] | data-search-target="input" |
| Values | static values = { delay: Number } | data-search-delay-value="300" |
| Classes | static classes = ["active"] | data-search-active-class="bg-blue" |
| Actions | - | data-action="click->search#submit" |
| Outlets | static outlets = ["form"] | data-search-form-outlet="#form" |
Basic Controller
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
search() {
fetch(`${this.urlValue}?q=${this.inputTarget.value}`)
.then(r => r.text())
.then(html => this.resultsTarget.innerHTML = html)
}
}
<div data-controller="search"
data-search-url-value="<%= search_path %>">
<input data-search-target="input"
data-action="input->search#search">
<div data-search-target="results"></div>
</div>
Action Modifiers
| Modifier | Effect |
|---|
:prevent | event.preventDefault() |
:stop | event.stopPropagation() |
.enter | Only on Enter key |
.esc | Only on Escape key |
.away | Click outside element |
<form data-action="submit->form#save:prevent">
<input data-action="keydown.enter->form#save:prevent">
</form>
Debugging Checklist
| Issue | Check |
|---|
| Frame not updating | Frame IDs match? |
| Streams not working | turbo_stream_from subscription? |
| Actions not firing | data-action syntax correct? Controller registered? |
| Morphing issues | data-turbo-permanent on persistent elements? |
References
Detailed patterns and examples in references/:
turbo-frames.md - Frame patterns, lazy loading, navigation
turbo-streams.md - Stream actions, broadcasts, form validation
stimulus-controllers.md - Targets, values, actions, classes, outlets
common-patterns.md - Infinite scroll, auto-submit, flash messages
accessibility.md - ARIA, keyboard navigation, focus management
testing.md - System tests, Stimulus controller tests
turbo8-native.md - Turbo 8 morphing, native apps