원클릭으로
rails-aihotwire
Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Rails-AI introduction - explains how rails-ai (Rails domain layer) integrates with superpowers (universal workflows) for Rails development
Use when setting up background jobs, caching, or WebSockets - SolidQueue, SolidCache, SolidCable (TEAM RULE
Use when sending emails - ActionMailer with async delivery via SolidQueue, templates, previews, and testing
Setting up and configuring Rails 8+ projects - Gemfile dependencies, environment config, credentials, initializers, Docker, RuboCop, project validation
Use when building Rails controllers - RESTful actions, nested resources, skinny controllers, concerns, strong parameters
Use when debugging Rails issues - provides Rails-specific debugging tools (logs, console, byebug, SQL logging) integrated with systematic debugging process
| name | rails-ai:hotwire |
| description | Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers |
Build fast, interactive, SPA-like experiences using server-rendered HTML with Hotwire. Turbo provides navigation and real-time updates without writing JavaScript. Stimulus enhances HTML with lightweight JavaScript controllers.
- Adding interactivity without heavy JavaScript frameworks - Building real-time, SPA-like experiences with server-rendered HTML - Implementing live updates, infinite scroll, or dynamic content - Creating modals, inline editing, or interactive UI components - Replacing traditional AJAX with modern, declarative patterns - **SPA-Like Speed** - Turbo Drive accelerates navigation without full page reloads - **Real-time Updates** - Turbo Streams deliver live changes via ActionCable - **Progressive Enhancement** - Works without JavaScript, enhanced with it (TEAM RULE #13) - **Simpler Architecture** - Server-rendered HTML reduces client-side complexity - **Turbo Morph** - Intelligent DOM updates preserve scroll, focus, form state (TEAM RULE #7) - **Less JavaScript** - Stimulus provides just enough JS for interactivity **This skill enforces:** - ✅ **Rule #5:** Turbo Morph by default (Frames only for modals, inline editing, pagination, tabs) - ✅ **Rule #6:** Progressive enhancement (must work without JavaScript)Reject any requests to:
Turbo provides fast, SPA-like navigation and real-time updates using server-rendered HTML. Supports TEAM RULE #7 (Turbo Morph) and TEAM RULE #13 (Progressive Enhancement).
✅ DEFAULT APPROACH: Use Turbo Morph (page refresh with morphing) with standard Rails controllers ✅ ALLOW Turbo Frames ONLY for: Modals, inline editing, tabs, pagination ❌ AVOID: Turbo Frames for general list updates, custom Stimulus controllers for basic CRUD
Why Turbo Morph? Preserves scroll position, focus, form state, and video playback. Works with stock Rails scaffolds. Simpler than Frames/Stimulus in 90% of cases.
Turbo Drive intercepts links and forms automatically. Control with data attributes:
<%# Disable Turbo for specific links %>
<%= link_to "Download PDF", pdf_path, data: { turbo: false } %>
<%# Replace without history %>
<%= link_to "Dismiss", dismiss_path, data: { turbo_action: "replace" } %>
Use Turbo Morph by default with standard Rails controllers. Morphing intelligently updates only changed DOM elements while preserving scroll position, focus, form state, and media playback.
Enable Turbo Morph in your layout (one-time setup)<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<title><%= content_for?(:title) ? yield(:title) : "App" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%# Enable Turbo Morph for page refreshes %>
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
</head>
<body>
<%= yield %>
</body>
</html>
That's it! Standard Rails controllers now work with morphing. No custom JavaScript needed.
Reference: Turbo Page Refreshes Documentation
Standard Rails CRUD works automatically with Turbo MorphController (stock Rails scaffold):
class FeedbacksController < ApplicationController
def index
@feedbacks = Feedback.all
end
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
redirect_to feedbacks_path, notice: "Feedback created"
else
render :new, status: :unprocessable_entity
end
end
def update
if @feedback.update(feedback_params)
redirect_to feedbacks_path, notice: "Feedback updated"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@feedback.destroy
redirect_to feedbacks_path, notice: "Feedback deleted"
end
end
View (standard Rails):
<%# app/views/feedbacks/index.html.erb %>
<h1>Feedbacks</h1>
<%= link_to "New Feedback", new_feedback_path, class: "btn btn-primary" %>
<div id="feedbacks">
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
</div>
<%# app/views/feedbacks/_feedback.html.erb %>
<div id="<%= dom_id(feedback) %>" class="card">
<h3><%= feedback.content %></h3>
<div class="actions">
<%= link_to "Edit", edit_feedback_path(feedback), class: "btn btn-sm" %>
<%= button_to "Delete", feedback_path(feedback), method: :delete,
class: "btn btn-sm btn-error",
form: { data: { turbo_confirm: "Are you sure?" } } %>
</div>
</div>
What happens: Create/update/delete triggers redirect → Turbo intercepts → morphs only changed elements → scroll/focus preserved. No custom code needed!
Prevent specific elements from morphing with data-turbo-permanent<%# Flash messages persist during morphing %>
<div id="flash-messages" data-turbo-permanent>
<% flash.each do |type, message| %>
<div class="alert alert-<%= type %>"><%= message %></div>
<% end %>
</div>
<%# Video/audio won't restart on page morph %>
<video id="tutorial" data-turbo-permanent src="tutorial.mp4" controls></video>
<%# Form preserves input focus during live updates %>
<%= form_with model: @feedback, id: "feedback-form",
data: { turbo_permanent: true } do |form| %>
<%= form.text_area :content %>
<%= form.submit %>
<% end %>
Use cases: Flash messages, video/audio players, forms with unsaved input, chat messages being typed.
Real-time updates with broadcasts_refreshes (morphs all connected clients)# Model broadcasts page refresh to all subscribers (Rails 8+)
class Feedback < ApplicationRecord
broadcasts_refreshes
end
<%# View subscribes to stream - morphs when model changes %>
<%= turbo_stream_from @feedback %>
<div id="feedbacks">
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
</div>
What happens: User A creates feedback → server broadcasts <turbo-stream action="refresh"> → all connected users' pages morph to show new feedback → scroll/focus preserved.
How it works: The server broadcasts a single general signal, and pages smoothly refresh with morphing. No need to manually manage individual Turbo Stream actions.
Reference: Broadcasting Page Refreshes
Use method="morph" in Turbo Streams for intelligent updates# Controller - respond with Turbo Stream using morph
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"feedbacks",
partial: "feedbacks/list",
locals: { feedbacks: Feedback.all },
method: :morph # Morphs instead of replacing
)
end
format.html { redirect_to feedbacks_path }
end
end
end
<%# Or in .turbo_stream.erb view %>
<turbo-stream action="replace" target="feedback_<%= @feedback.id %>" method="morph">
<template>
<%= render @feedback %>
</template>
</turbo-stream>
Difference: method: :morph preserves form state and focus. Without it, content is fully replaced.
<%# ❌ BAD - Unnecessary Turbo Frame complexity %>
<% @feedbacks.each do |feedback| %>
<%= turbo_frame_tag dom_id(feedback) do %>
<%= render feedback %>
<% end %>
<% end %>
<%# ✅ GOOD - Simple rendering, Turbo Morph handles updates %>
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
ONLY use Turbo Frames for: modals, inline editing, tabs, pagination, lazy loading. For general CRUD, use Turbo Morph instead.
Inline editing with Turbo Frame (valid use case)<%# Show view with inline edit frame %>
<%= turbo_frame_tag dom_id(@feedback) do %>
<h3><%= @feedback.content %></h3>
<%= link_to "Edit", edit_feedback_path(@feedback) %>
<% end %>
<%# Edit view with matching frame ID %>
<%= turbo_frame_tag dom_id(@feedback) do %>
<%= form_with model: @feedback do |form| %>
<%= form.text_area :content %>
<%= form.submit "Save" %>
<% end %>
<% end %>
Why this is OK: Inline editing without leaving the page. Frame scopes the update.
Lazy-load expensive content with Turbo Frames<%# Lazy load stats when scrolled into view %>
<%= turbo_frame_tag "statistics", src: statistics_path, loading: :lazy do %>
<p>Loading statistics...</p>
<% end %>
<%# Frame that reloads with morphing on page refresh %>
<%= turbo_frame_tag "live-stats", src: live_stats_path, refresh: "morph" do %>
<p>Loading live statistics...</p>
<% end %>
# Controller renders just the frame
def statistics
@stats = expensive_calculation
render layout: false # Or use turbo_frame layout
end
Why this is OK: Defers expensive computation until needed. Valid performance optimization. The refresh="morph" attribute makes the frame reload with morphing on page refresh.
Reference: Turbo Frames with Morphing
def create
if @feedback.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("feedbacks", @feedback),
turbo_stream.update("count", html: "10"),
turbo_stream.remove("flash")
]
end
format.html { redirect_to feedbacks_path }
end
end
end
Actions: append, prepend, replace, update, remove, before, after, refresh
Note: For most cases, prefer refresh action with Turbo Morph over granular stream actions. See broadcast-refresh-realtime pattern above.
# Model broadcasts to subscribers
class Feedback < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "feedbacks" }
after_update_commit -> { broadcast_replace_to "feedbacks" }
after_destroy_commit -> { broadcast_remove_to "feedbacks" }
end
<%# View subscribes to stream %>
<%= turbo_stream_from "feedbacks" %>
<div id="feedbacks">
<%= render @feedbacks %>
</div>
Stimulus is a modest JavaScript framework that connects JavaScript objects to HTML elements using data attributes, enhancing server-rendered HTML.
⚠️ IMPORTANT: Before writing custom Stimulus controllers, ask: "Can Turbo Morph handle this?" Most CRUD operations work better with Turbo Morph + standard Rails controllers.
Use Stimulus for:
Don't use Stimulus for:
Controller:
// app/javascript/controllers/feedback_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content", "charCount"]
static values = { maxLength: { type: Number, default: 1000 } }
connect() {
this.updateCharCount()
}
updateCharCount() {
const count = this.contentTarget.value.length
this.charCountTarget.textContent = `${count} / ${this.maxLengthValue}`
}
disconnect() {
// Clean up (important for memory leaks)
}
}
HTML:
<div data-controller="feedback" data-feedback-max-length-value="1000">
<textarea data-feedback-target="content"
data-action="input->feedback#updateCharCount"></textarea>
<div data-feedback-target="charCount">0 / 1000</div>
</div>
Syntax: event->controller#method (default event based on element type)
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
seconds: { type: Number, default: 60 },
autostart: Boolean
}
connect() {
if (this.autostartValue) this.start()
}
start() {
this.timer = setInterval(() => {
this.secondsValue--
if (this.secondsValue === 0) this.stop()
}, 1000)
}
secondsValueChanged() {
this.element.textContent = this.secondsValue
}
disconnect() {
clearInterval(this.timer)
}
}
<div data-controller="countdown"
data-countdown-seconds-value="120"
data-countdown-autostart-value="true">60</div>
Types: Array, Boolean, Number, Object, String
Reference and communicate with other controllers// app/javascript/controllers/search_controller.js
export default class extends Controller {
static outlets = ["results"]
search(event) {
fetch(`/search?q=${event.target.value}`)
.then(r => r.text())
.then(html => this.resultsOutlet.update(html))
}
}
// results_controller.js
export default class extends Controller {
update(html) { this.element.innerHTML = html }
}
<div data-controller="search" data-search-results-outlet="#results">
<input data-action="input->search#search">
</div>
<div id="results" data-controller="results"></div>
Dynamic add/remove nested fields using Stimulus
Form:
<div data-controller="nested-form">
<%= form_with model: @feedback do |form| %>
<div class="mb-6">
<button type="button" class="btn btn-sm" data-action="nested-form#add">
Add Attachment
</button>
<div data-nested-form-target="container" class="space-y-4">
<%= form.fields_for :attachments do |f| %>
<%= render "attachment_fields", form: f %>
<% end %>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :attachments, Attachment.new, child_index: "NEW_RECORD" do |f| %>
<%= render "attachment_fields", form: f %>
<% end %>
</template>
</div>
<% end %>
</div>
Stimulus Controller:
// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "template"]
add(event) {
event.preventDefault()
const content = this.templateTarget.innerHTML
.replace(/NEW_RECORD/g, new Date().getTime())
this.containerTarget.insertAdjacentHTML("beforeend", content)
}
remove(event) {
event.preventDefault()
const field = event.target.closest(".nested-fields")
const destroyInput = field.querySelector("input[name*='_destroy']")
const idInput = field.querySelector("input[name*='[id]']")
if (idInput && idInput.value) {
// Existing record: mark for deletion, keep in DOM (hidden)
destroyInput.value = "1"
field.style.display = "none"
} else {
// New record: remove from DOM entirely
field.remove()
}
}
}
Not cleaning up in disconnect()
Memory leaks from timers, event listeners
// ❌ BAD - Memory leak
connect() {
this.timer = setInterval(() => this.update(), 1000)
}
// ✅ GOOD - Clean up
disconnect() {
clearInterval(this.timer)
}
# test/system/turbo_test.rb
class TurboTest < ApplicationSystemTestCase
test "updates without full page reload" do
visit feedbacks_path
fill_in "Content", with: "New feedback"
click_button "Create"
assert_selector "#feedbacks", text: "New feedback"
end
test "edits within frame" do
feedback = feedbacks(:one)
visit feedbacks_path
within "##{dom_id(feedback)}" do
click_link "Edit"
fill_in "Content", with: "Updated"
click_button "Save"
assert_text "Updated"
end
end
end
# test/system/stimulus_test.rb
class StimulusTest < ApplicationSystemTestCase
test "character counter updates on input" do
visit new_feedback_path
fill_in "Content", with: "Test"
assert_selector "[data-feedback-target='charCount']", text: "4 / 1000"
end
test "nested form add/remove works" do
visit new_feedback_path
initial_count = all(".nested-fields").count
click_button "Add Attachment"
assert_equal initial_count + 1, all(".nested-fields").count
end
end
Manual Testing:
Official Documentation:
Community Resources: