rails-hotwire
Originally fromthebushidocollective/han
SKILL.md
Rails 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
Weekly Installs
3
Repository
shoebtamboli/ra…e_skillsGitHub Stars
4
First Seen
Mar 1, 2026
Security Audits
Installed on
opencode3
gemini-cli3
github-copilot3
codex3
amp3
cline3