
Hotwire Patterns
- 2 installs
- Updated February 9, 2026
- dchuk/rails_ai_agents
Implements Hotwire patterns in Rails 8 with Turbo Frames, Turbo Streams, and Stimulus controllers for interactive UIs.
About
Builds interactive UIs and real-time updates in Rails 8 using Turbo Frames, Turbo Streams, and Stimulus. A developer uses it for partial page updates and form handling without much JavaScript.
- Turbo Frames and Turbo Streams for partial and real-time updates
- Stimulus controllers for interactivity
Hotwire Patterns by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,863 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dchuk/rails_ai_agents --skill hotwire-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | February 9, 2026 |
| Repository | dchuk/rails_ai_agents ↗ |
What it does
Implements Hotwire patterns in Rails 8 with Turbo Frames, Turbo Streams, and Stimulus controllers for interactive UIs.
Files
Hotwire Patterns for Rails 8
Overview
Hotwire = HTML Over The Wire. Build modern web apps without writing much JavaScript.
| Component | Purpose | Use Case |
|---|---|---|
| Turbo Drive | SPA-like navigation | Automatic, no code needed |
| Turbo Frames | Partial page updates | Inline editing, tabbed content |
| Turbo Streams | Real-time DOM updates | Live updates, flash messages |
| Stimulus | JavaScript sprinkles | Toggles, forms, interactions |
When to Use Each Pattern
| Scenario | Pattern |
|---|---|
| Inline edit | Turbo Frame |
| Form submission with multiple updates | Turbo Stream |
| Real-time feed | Turbo Stream + ActionCable |
| Toggle visibility | Stimulus |
| Form validation | Stimulus |
| Infinite scroll | Turbo Frame + lazy loading |
| Modal dialogs | Turbo Frame |
| Flash messages | Turbo Stream |
References
- See turbo-frames.md for frame patterns
- See turbo-streams.md for stream patterns
- See stimulus.md for controller patterns
- See tailwind-integration.md for styling
Turbo Frames
Basic Frame
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts" do %>
<%= render @posts %>
<%= link_to "Load More", posts_path(page: 2) %>
<% end %>Inline Editing
<%# _post.html.erb %>
<%= turbo_frame_tag dom_id(post) do %>
<article>
<h2><%= post.title %></h2>
<%= link_to "Edit", edit_post_path(post) %>
</article>
<% end %>
<%# edit.html.erb %>
<%= turbo_frame_tag dom_id(@post) do %>
<%= form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<%= link_to "Cancel", @post %>
<% end %>
<% end %>Lazy Loading
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>Turbo Streams
From Controller
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts", @post %>
<%= turbo_stream.update "flash", partial: "shared/flash" %>Stream Actions
turbo_stream.append "posts", @post # Add to end
turbo_stream.prepend "posts", @post # Add to start
turbo_stream.replace dom_id(@post), @post # Replace element
turbo_stream.update dom_id(@post), @post # Replace inner HTML
turbo_stream.remove dom_id(@post) # Remove elementFlash Messages with Streams
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
after_action :flash_to_turbo_stream, if: -> { request.format.turbo_stream? }
private
def flash_to_turbo_stream
flash.each do |type, message|
flash.now[type] = message
end
end
endStimulus Controllers
Basic Controller
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
toggle() {
this.contentTarget.classList.toggle("hidden")
}
}<div data-controller="toggle">
<button data-action="toggle#toggle">Toggle</button>
<div data-toggle-target="content">Hidden content</div>
</div>Form Controller
// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit"]
enableSubmit() {
this.submitTarget.disabled = false
}
disableSubmit() {
this.submitTarget.disabled = true
}
}Testing Hotwire
Turbo Stream Response Tests
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:one)
end
test "create returns turbo stream response" do
post posts_path,
params: { post: { title: "Test" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_equal "text/vnd.turbo-stream.html", response.media_type
assert_includes response.body, "turbo-stream"
end
test "create with HTML format redirects" do
post posts_path, params: { post: { title: "Test" } }
assert_redirected_to post_path(Post.last)
end
endSystem Tests (with JavaScript)
# test/system/posts_test.rb
require "application_system_test_case"
class PostsSystemTest < ApplicationSystemTestCase
setup do
@user = users(:one)
sign_in @user
end
test "updates post inline with Turbo Frame" do
post = posts(:one)
visit posts_path
within("#post_#{post.id}") do
click_link "Edit"
fill_in "Title", with: "Updated"
click_button "Save"
end
assert_text "Updated"
assert_no_text post.title
end
test "adds comment with Turbo Stream" do
post = posts(:one)
visit post_path(post)
fill_in "Comment", with: "Great post!"
click_button "Add Comment"
within("#comments") do
assert_text "Great post!"
end
end
endDebugging Tips
1. Frame not updating? Check frame IDs match exactly 2. Stream not working? Verify Accept header includes turbo-stream 3. Stimulus not firing? Check controller name matches file name 4. Events not working? Use data-action="event->controller#method"
Checklist
- [ ] Identify update scope (full page vs partial)
- [ ] Choose pattern (Frame vs Stream vs Stimulus)
- [ ] Implement server response
- [ ] Add client-side markup
- [ ] Test with and without JavaScript
- [ ] Write system test for interactive behavior
- [ ] All tests GREEN
Stimulus Reference
Concept
Stimulus is a modest JavaScript framework for adding behavior to HTML. It connects JavaScript objects (controllers) to DOM elements using data attributes.
Core Concepts
| Concept | Purpose | Attribute |
|---|---|---|
| Controller | JavaScript class | data-controller="name" |
| Action | Event handler | data-action="event->controller#method" |
| Target | DOM reference | data-controller-target="name" |
| Value | Reactive data | data-controller-name-value="x" |
| Class | CSS class reference | data-controller-name-class="x" |
| Outlet | Cross-controller reference | data-controller-name-outlet=".selector" |
Basic Controller
// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["output"]
greet() {
this.outputTarget.textContent = "Hello, Stimulus!"
}
}<div data-controller="hello">
<button data-action="click->hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>Controller Lifecycle
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// Called when controller connects to DOM
connect() {
console.log("Connected!", this.element)
}
// Called when controller disconnects
disconnect() {
console.log("Disconnected!")
}
// Called when target is added
outputTargetConnected(element) {
console.log("Target connected:", element)
}
// Called when target is removed
outputTargetDisconnected(element) {
console.log("Target disconnected:", element)
}
}Targets
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output", "item"]
// Single target (first match)
copy() {
this.outputTarget.textContent = this.inputTarget.value
}
// Multiple targets (all matches)
clearAll() {
this.itemTargets.forEach(el => el.remove())
}
// Check if target exists
validate() {
if (this.hasOutputTarget) {
this.outputTarget.classList.add("validated")
}
}
}<div data-controller="form">
<input data-form-target="input" type="text">
<div data-form-target="output"></div>
<div data-form-target="item">Item 1</div>
<div data-form-target="item">Item 2</div>
</div>Values
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
count: { type: Number, default: 0 },
enabled: Boolean,
config: Object,
items: Array
}
connect() {
console.log(this.urlValue) // String
console.log(this.countValue) // Number
console.log(this.enabledValue) // Boolean
}
// Called when value changes
countValueChanged(value, previousValue) {
console.log(`Count changed from ${previousValue} to ${value}`)
}
increment() {
this.countValue++ // Triggers countValueChanged
}
}<div data-controller="counter"
data-counter-url-value="/api/count"
data-counter-count-value="5"
data-counter-enabled-value="true"
data-counter-config-value='{"max": 100}'>
</div>Actions
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// Basic action
submit() {
console.log("Submitted!")
}
// With event parameter
handleClick(event) {
event.preventDefault()
console.log("Clicked:", event.target)
}
// With params
delete(event) {
const id = event.params.id
console.log("Delete item:", id)
}
}<div data-controller="items">
<%# Basic action %>
<button data-action="click->items#submit">Submit</button>
<%# Multiple events %>
<input data-action="input->items#validate focus->items#highlight">
<%# Shorthand (click is default for buttons) %>
<button data-action="items#submit">Submit</button>
<%# With params %>
<button data-action="items#delete" data-items-id-param="123">Delete</button>
<%# Prevent default %>
<form data-action="submit->items#handleSubmit:prevent">
<%# Stop propagation %>
<button data-action="click->items#handle:stop">Click</button>
</div>Common Patterns
Toggle Visibility
// toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTarget.classList.toggle(this.hiddenClass)
}
show() {
this.contentTarget.classList.remove(this.hiddenClass)
}
hide() {
this.contentTarget.classList.add(this.hiddenClass)
}
}<div data-controller="toggle" data-toggle-hidden-class="hidden">
<button data-action="toggle#toggle">Toggle</button>
<div data-toggle-target="content">Content here</div>
</div>Form Validation
// validation_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "error", "submit"]
validate() {
const isValid = this.inputTarget.value.length >= 3
this.errorTarget.textContent = isValid ? "" : "Minimum 3 characters"
this.submitTarget.disabled = !isValid
}
}Debounced Search
// search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.performSearch()
}, 300)
}
async performSearch() {
const query = this.inputTarget.value
const response = await fetch(`${this.urlValue}?q=${query}`)
this.resultsTarget.innerHTML = await response.text()
}
}Clipboard
// clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = { successMessage: { type: String, default: "Copied!" } }
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.showNotification()
}
showNotification() {
// Show temporary feedback
}
}File Naming
app/javascript/controllers/
├── application.js # Auto-generated
├── index.js # Auto-generated
├── hello_controller.js # data-controller="hello"
├── clipboard_controller.js # data-controller="clipboard"
└── nested/
└── form_controller.js # data-controller="nested--form"Debugging
// Enable debug mode
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = true // Logs controller lifecycleTailwind CSS Integration with Hotwire
Principles
- Mobile-first responsive design
- Semantic HTML with accessibility
- Consistent color palette and spacing
- Focus states on all interactive elements
Responsive Breakpoints
sm: 640px+ (small tablets)
md: 768px+ (tablets)
lg: 1024px+ (desktops)
xl: 1280px+ (large desktops)Common Patterns
Responsive Grid
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<%= render @items %>
</div>Button Variants
<%# Primary %>
<%= link_to "Save", path, class: "bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors" %>
<%# Secondary %>
<%= link_to "Cancel", path, class: "bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold py-2 px-4 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors" %>
<%# Danger %>
<%= button_to "Delete", path, method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "bg-red-600 hover:bg-red-700 text-white font-semibold py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors" %>Form Fields
<div class="space-y-1">
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= f.text_field :name, class: "w-full px-3 py-2 rounded-md border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 transition-colors", placeholder: "Enter name..." %>
</div>Cards
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">Title</h3>
<p class="text-gray-600">Content</p>
</div>Badges
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Inactive</span>Alerts
<div class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-md" role="alert">
<p class="font-medium">Success!</p>
</div>Turbo-Specific Styling
Turbo Frame Loading State
<turbo-frame id="comments" src="<%= comments_path %>" loading="lazy" class="space-y-4">
<div class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</turbo-frame>Skeleton Loader
<div class="animate-pulse space-y-4">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>Accessibility
- Use semantic HTML (
<nav>,<main>,<article>,<button>) - Include
aria-labelfor icon-only buttons - Ensure focus states with
focus:ring-classes - Add
sr-onlyclass for screen-reader-only text - Minimum contrast ratio WCAG AA: 4.5:1
Color Usage
| Color | Purpose |
|---|---|
blue-* | Primary actions, links |
green-* | Success, confirmations |
red-* | Errors, destructive actions |
yellow-* | Warnings |
gray-* | Neutral, borders, disabled |
Turbo Frames Reference
Concept
Turbo Frames scope navigation to a portion of the page. When a link or form inside a frame is activated, only that frame's content is replaced.
Basic Usage
Define a Frame
<%= turbo_frame_tag "user_profile" do %>
<h2><%= @user.name %></h2>
<%= link_to "Edit", edit_user_path(@user) %>
<% end %>Match Frame in Response
<%# edit.html.erb - must have matching frame %>
<%= turbo_frame_tag "user_profile" do %>
<%= form_with model: @user do |f| %>
<%= f.text_field :name %>
<%= f.submit %>
<% end %>
<% end %>Frame Attributes
| Attribute | Purpose | Example |
|---|---|---|
id | Frame identifier | turbo_frame_tag "posts" |
src | Lazy load URL | src: posts_path |
loading | Load timing | loading: :lazy |
target | Navigation target | target: "_top" |
disabled | Disable frame | disabled: true |
Common Patterns
Lazy Loading
<%# Load content when frame enters viewport %>
<%= turbo_frame_tag "comments",
src: post_comments_path(@post),
loading: :lazy do %>
<p>Loading comments...</p>
<% end %>Breaking Out of Frame
<%# Link navigates full page, not just frame %>
<%= link_to "View All", posts_path, data: { turbo_frame: "_top" } %>
<%# Or in the frame tag %>
<%= turbo_frame_tag "modal", target: "_top" do %>
...
<% end %>Targeting Different Frame
<%# Link updates a different frame %>
<%= link_to "Details", post_path(@post), data: { turbo_frame: "post_details" } %>
<%# This frame will be updated %>
<%= turbo_frame_tag "post_details" do %>
<p>Select a post to see details</p>
<% end %>Inline Editing Pattern
<%# Show mode %>
<%= turbo_frame_tag dom_id(post) do %>
<div class="post">
<h3><%= post.title %></h3>
<p><%= post.body %></p>
<%= link_to "Edit", edit_post_path(post) %>
</div>
<% end %>
<%# Edit mode (edit.html.erb) %>
<%= turbo_frame_tag dom_id(@post) do %>
<%= form_with model: @post, data: { turbo_frame: dom_id(@post) } do |f| %>
<%= f.text_field :title %>
<%= f.text_area :body %>
<%= f.submit "Save" %>
<%= link_to "Cancel", @post %>
<% end %>
<% end %>Modal Pattern
<%# Trigger link %>
<%= link_to "New Post", new_post_path, data: { turbo_frame: "modal" } %>
<%# Modal frame (in layout) %>
<%= turbo_frame_tag "modal" %>
<%# new.html.erb %>
<%= turbo_frame_tag "modal" do %>
<div class="modal-backdrop">
<div class="modal-content">
<h2>New Post</h2>
<%= form_with model: @post do |f| %>
...
<% end %>
<%= link_to "Close", root_path, data: { turbo_frame: "modal" } %>
</div>
</div>
<% end %>Tab Navigation
<nav>
<%= link_to "Details", post_details_path(@post), data: { turbo_frame: "tab_content" } %>
<%= link_to "Comments", post_comments_path(@post), data: { turbo_frame: "tab_content" } %>
<%= link_to "History", post_history_path(@post), data: { turbo_frame: "tab_content" } %>
</nav>
<%= turbo_frame_tag "tab_content" do %>
<%= render "details" %>
<% end %>Frame Events
// Listen for frame events
document.addEventListener("turbo:frame-load", (event) => {
console.log("Frame loaded:", event.target.id)
})
document.addEventListener("turbo:frame-missing", (event) => {
console.log("Frame not found in response:", event.target.id)
event.preventDefault() // Handle gracefully
})Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Frame not updating | ID mismatch | Ensure frame IDs match exactly |
| Full page reload | Missing frame in response | Add matching frame tag |
| Content disappears | Empty frame returned | Check controller response |
| Wrong frame updates | Multiple frames with same ID | Use unique IDs |
Turbo Streams Reference
Concept
Turbo Streams deliver page changes as a set of actions to be performed on specific DOM elements. They can append, prepend, replace, update, remove, before, or after.
Stream Actions
| Action | Purpose | Example |
|---|---|---|
append | Add to end of container | Add new item to list |
prepend | Add to start of container | Add newest item first |
replace | Replace entire element | Update a record |
update | Replace inner HTML only | Update content, keep element |
remove | Delete element | Remove deleted record |
before | Insert before element | Insert above |
after | Insert after element | Insert below |
Basic Usage
Controller Response
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream # renders create.turbo_stream.erb
format.html { redirect_to @post }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("post_form", partial: "form", locals: { post: @post }) }
format.html { render :new }
end
end
endTurbo Stream Template
<%# app/views/posts/create.turbo_stream.erb %>
<%# Add new post to list %>
<%= turbo_stream.prepend "posts", @post %>
<%# Clear the form %>
<%= turbo_stream.replace "post_form", partial: "posts/form", locals: { post: Post.new } %>
<%# Update flash message %>
<%= turbo_stream.update "flash", partial: "shared/flash" %>
<%# Update counter %>
<%= turbo_stream.update "posts_count", html: "#{Post.count} posts" %>Stream Helpers
Basic Helpers
<%# Append partial to container %>
<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>
<%# Append renderable (auto-finds partial) %>
<%= turbo_stream.append "posts", @post %>
<%# Prepend to container %>
<%= turbo_stream.prepend "posts", @post %>
<%# Replace element entirely %>
<%= turbo_stream.replace dom_id(@post), @post %>
<%# Update inner HTML %>
<%= turbo_stream.update dom_id(@post), @post %>
<%# Remove element %>
<%= turbo_stream.remove dom_id(@post) %>
<%# Insert before element %>
<%= turbo_stream.before dom_id(@other_post), @post %>
<%# Insert after element %>
<%= turbo_stream.after dom_id(@other_post), @post %>Inline Content
<%# With HTML string %>
<%= turbo_stream.update "counter", html: "<strong>5</strong> items" %>
<%# With text %>
<%= turbo_stream.update "status", text: "Processing complete" %>
<%# With block %>
<%= turbo_stream.update "notification" do %>
<div class="alert alert-success">
Post created successfully!
</div>
<% end %>Real-time with ActionCable
Broadcast from Model
# app/models/post.rb
class Post < ApplicationRecord
after_create_commit { broadcast_prepend_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
endSubscribe in View
<%# Subscribe to stream %>
<%= turbo_stream_from "posts" %>
<%# Container that receives updates %>
<div id="posts">
<%= render @posts %>
</div>Broadcast from Controller/Job
# Broadcast to all subscribers
Turbo::StreamsChannel.broadcast_prepend_to(
"posts",
target: "posts",
partial: "posts/post",
locals: { post: @post }
)
# Or use helper
broadcast_prepend_to "posts", target: "posts", partial: "posts/post", locals: { post: @post }Multiple Streams Response
<%# app/views/comments/create.turbo_stream.erb %>
<%# Add comment to list %>
<%= turbo_stream.append "comments", @comment %>
<%# Update comment count %>
<%= turbo_stream.update "comment_count" do %>
<%= pluralize(@post.comments.count, "comment") %>
<% end %>
<%# Clear form %>
<%= turbo_stream.replace "new_comment" do %>
<%= render "comments/form", comment: Comment.new(post: @post) %>
<% end %>
<%# Show flash %>
<%= turbo_stream.prepend "flashes" do %>
<div class="flash flash-success">Comment added!</div>
<% end %>Testing Turbo Streams
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
test "returns turbo stream on success" do
post posts_path,
params: { post: { title: "Test" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_equal "text/vnd.turbo-stream.html", response.media_type
assert_includes response.body, 'turbo-stream action="prepend"'
end
endCommon Patterns
Flash Messages
<%# Layout %>
<div id="flashes">
<%= render "shared/flash" %>
</div>
<%# In turbo_stream response %>
<%= turbo_stream.update "flashes", partial: "shared/flash" %>Form Errors
<%# On validation failure %>
<%= turbo_stream.replace "post_form" do %>
<%= render "form", post: @post %>
<% end %>Live Counter
<%# Initial render %>
<span id="online_count"><%= @online_count %></span>
<%# Broadcast update %>
<%= turbo_stream.update "online_count", html: @new_count.to_s %>