rails-hotwire
npx machina-cli add skill Shoebtamboli/rails_claude_skills/rails-hotwire --openclawRails Hotwire
Hotwire is an alternative approach to building modern web applications without using much JavaScript. It consists of Turbo (Drive, Frames, Streams) and Stimulus.
Quick Reference
| Component | Purpose |
|---|---|
| Turbo Drive | Fast page navigation without full reload |
| Turbo Frames | Decompose pages into independent contexts |
| Turbo Streams | Deliver page changes as HTML over WebSocket or in response to form submissions |
| Stimulus | JavaScript sprinkles for enhanced interactivity |
Turbo Drive
Turbo Drive automatically makes all link clicks and form submissions use AJAX, replacing the page body without full reload.
<%# Turbo Drive is enabled by default in Rails 7+ %>
<%# Links and forms automatically use Turbo Drive %>
<%= link_to "Posts", posts_path %>
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
<%# Opt-out for specific elements %>
<div data-turbo="false">
<%= link_to "Normal Link", some_path %>
</div>
<%# Programmatic navigation %>
<a href="/posts" data-turbo-action="advance">Posts</a>
<a href="/posts" data-turbo-action="replace">Posts (Replace History)</a>
Turbo Frames
Turbo Frames allow you to scope navigation and form submissions to a specific part of the page.
Basic Frame
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts_list" do %>
<%= render @posts %>
<%= link_to "New Post", new_post_path %>
<% end %>
<%# app/views/posts/new.html.erb %>
<%= turbo_frame_tag "posts_list" do %>
<h2>New Post</h2>
<%= form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
<% end %>
Lazy Loading Frames
<%# Load content on demand %>
<%= turbo_frame_tag "post_#{post.id}", src: post_path(post), loading: :lazy do %>
<div class="loading">Loading post...</div>
<% end %>
Targeting Frames
<%# Target a specific frame from outside %>
<%= link_to "Edit", edit_post_path(@post), data: { turbo_frame: "modal" } %>
<%= link_to "Break out of frame", posts_path, data: { turbo_frame: "_top" } %>
<%# Turbo Frame that breaks out by default %>
<%= turbo_frame_tag "navigation", target: "_top" do %>
<%= link_to "Home", root_path %>
<% end %>
Turbo Streams
Turbo Streams let you update multiple parts of the page in response to actions.
Stream Actions
<%# app/views/posts/create.turbo_stream.erb %>
<%# Append to a container %>
<%= turbo_stream.append "posts" do %>
<%= render @post %>
<% end %>
<%# Prepend to a container %>
<%= turbo_stream.prepend "posts" do %>
<%= render @post %>
<% end %>
<%# Replace an element %>
<%= turbo_stream.replace @post do %>
<%= render @post %>
<% end %>
<%# Update content of an element %>
<%= turbo_stream.update "post_#{@post.id}" do %>
<%= render @post %>
<% end %>
<%# Remove an element %>
<%= turbo_stream.remove "post_#{@post.id}" %>
<%# Insert before/after %>
<%= turbo_stream.before "post_#{@post.id}" do %>
<div class="notice">Updated!</div>
<% end %>
<%= turbo_stream.after "post_#{@post.id}" do %>
<div class="ad">Advertisement</div>
<% end %>
Controller Setup
class PostsController < ApplicationController
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream
format.html { redirect_to @post }
else
format.turbo_stream { render :form_errors, status: :unprocessable_entity }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def update
respond_to do |format|
if @post.update(post_params)
format.turbo_stream
format.html { redirect_to @post }
else
format.turbo_stream { render :form_errors, status: :unprocessable_entity }
format.html { render :edit, status: :unprocessable_entity }
end
end
end
def destroy
@post.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@post) }
format.html { redirect_to posts_path }
end
end
end
Broadcast Updates (Real-time)
# app/models/post.rb
class Post < ApplicationRecord
broadcasts_to ->(post) { "posts" }, inserts_by: :prepend
# Or more control
after_create_commit -> { broadcast_prepend_to "posts" }
after_update_commit -> { broadcast_replace_to "posts" }
after_destroy_commit -> { broadcast_remove_to "posts" }
# Broadcast to specific users/channels
after_create_commit -> { broadcast_prepend_to [author, "posts"], target: "posts_list" }
end
<%# Subscribe to broadcasts %>
<%= turbo_stream_from "posts" %>
<div id="posts">
<%= render @posts %>
</div>
Stimulus
Stimulus is a modest JavaScript framework for enhancing static or server-rendered HTML.
Basic Controller
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = {
message: String,
count: { type: Number, default: 0 }
}
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.countValue++
alert(`Copied! (${this.countValue} times)`)
}
}
<div data-controller="clipboard">
<input data-clipboard-target="source" type="text" value="Copy me!">
<button data-action="click->clipboard#copy">Copy to Clipboard</button>
</div>
Common Patterns
// Toggle visibility
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="click->toggle#toggle">Toggle</button>
<div data-toggle-target="content" class="hidden">
Hidden content
</div>
</div>
// Form autosave
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "status"]
async save() {
this.statusTarget.textContent = "Saving..."
const formData = new FormData(this.formTarget)
const response = await fetch(this.formTarget.action, {
method: this.formTarget.method,
body: formData,
headers: {
"X-CSRF-Token": document.querySelector("[name='csrf-token']").content
}
})
if (response.ok) {
this.statusTarget.textContent = "Saved!"
} else {
this.statusTarget.textContent = "Error saving"
}
}
}
<div data-controller="autosave">
<%= form_with model: @post, data: { autosave_target: "form", action: "change->autosave#save" } do |f| %>
<%= f.text_area :content %>
<% end %>
<span data-autosave-target="status"></span>
</div>
Best Practices
- Use Turbo Frames for independent page sections
- Use Turbo Streams for real-time updates and multi-part updates
- Keep Stimulus controllers small and focused on one behavior
- Use Stimulus values instead of reading from the DOM
- Graceful degradation - ensure functionality works without JavaScript when possible
- Use broadcasts for real-time features (chat, notifications, etc.)
- Test Turbo interactions with system tests
- Use
data-turbo-confirmfor destructive actions - Optimize by lazy-loading frames and using caching
Common Patterns
Modal with Turbo Frame
<%# Layout %>
<div id="modal" class="modal">
<%= turbo_frame_tag "modal_content" %>
</div>
<%# Link that opens modal %>
<%= link_to "New Post", new_post_path, data: { turbo_frame: "modal_content" } %>
<%# app/views/posts/new.html.erb %>
<%= turbo_frame_tag "modal_content" do %>
<h2>New Post</h2>
<%= render "form", post: @post %>
<% end %>
Inline Editing
<%# Show mode %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<h2><%= @post.title %></h2>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# Edit mode %>
<%# app/views/posts/edit.html.erb %>
<%= turbo_frame_tag "post_#{@post.id}" do %>
<%= form_with model: @post do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>
<% end %>
Pagination with Turbo Frames
<%= turbo_frame_tag "posts" do %>
<%= render @posts %>
<%= link_to "Load More", posts_path(page: @next_page) %>
<% end %>
Troubleshooting
- Frame not updating: Check that frame IDs match
- Form not working: Ensure you're using
form_withnotform_for - Turbo Drive issues: Use
data-turbo="false"to opt-out - Broadcasts not working: Check ActionCable is configured
- Stimulus controller not connecting: Check console for errors, verify data-controller name
References
Source
git clone https://github.com/Shoebtamboli/rails_claude_skills/blob/main/lib/generators/claude/skills_library/rails-hotwire/SKILL.mdView on GitHub Overview
Rails Hotwire lets you build modern apps with minimal JavaScript by using Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus. It delivers fast, server-rendered HTML updates either over WebSocket or in response to form submissions, reducing front-end complexity.
How This Skill Works
Turbo Drive intercepts links and forms to fetch the body via AJAX, avoiding full page reloads. Turbo Frames scope navigation and updates to page fragments, while Turbo Streams push HTML changes to the DOM; Stimulus provides small JavaScript sprinkles for enhanced interactivity.
When to Use It
- You want fast navigation and updates with minimal client-side JavaScript
- You need partial page updates using Turbo Frames
- You want real-time or incremental UI changes via Turbo Streams
- You prefer server-rendered HTML with lightweight client behavior
- You are upgrading a Rails 7+ app and want Turbo + Stimulus integration
Quick Start
- Step 1: Ensure Rails 7+ and that Turbo is enabled by default in your app
- Step 2: Wrap interactive sections with turbo_frame_tag to scope updates
- Step 3: In controllers, respond with turbo_stream templates to push DOM changes
Best Practices
- Default to Turbo Drive for navigation to avoid full reloads
- Structure UI with Turbo Frames to isolate components
- Return Turbo Stream responses to update parts of the page
- Keep JavaScript lean by using Stimulus for small interactivity
- Test both HTML and Turbo Stream responses to ensure consistent state
Example Use Cases
- Append or prepend a new post to a list after creation using turbo_stream
- Open a modal for editing a post inside a turbo_frame and submit without leaving the page
- Load a post's content lazily using a turbo_frame with a src attribute
- Replace or update a post element via turbo_stream to reflect changes
- Navigate within a frame or break out to the full page using turbo_frame data attributes