--- name: rails-ai:hotwire description: Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers --- # Hotwire (Turbo + Stimulus) 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:** - Use Turbo Frames everywhere (use Turbo Morph for general CRUD) - Skip progressive enhancement (features that require JavaScript to function) - Build non-functional UIs without JavaScript fallbacks Before completing Hotwire features: - ✅ Works without JavaScript (progressive enhancement verified) - ✅ Turbo Morph used for CRUD operations (not Frames) - ✅ Turbo Frames only for: modals, inline editing, pagination, tabs - ✅ Stimulus controllers clean up in disconnect() - ✅ All interactive features tested - ✅ All tests passing - **TEAM RULE #7:** Prefer Turbo Morph over Turbo Frames/Stimulus for general CRUD - **TEAM RULE #13:** Ensure progressive enhancement (works without JavaScript) - Use Turbo Drive for automatic page acceleration - Use Turbo Morph for list updates and CRUD operations (preserves state) - Use Turbo Frames ONLY for: modals, inline editing, tabs, pagination, lazy loading - Use Turbo Streams for real-time updates via ActionCable - Use Stimulus for client-side interactions (dropdowns, character counters, dynamic forms) - Always clean up in Stimulus disconnect() to prevent memory leaks - Test with JavaScript disabled to verify progressive enhancement --- ## Hotwire Turbo 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). ### TEAM RULE #7: Prefer Turbo Morph over Turbo Frames/Stimulus ✅ **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 Automatic page acceleration with Turbo Drive Turbo Drive intercepts links and forms automatically. Control with `data` attributes: ```erb <%# 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" } %> ``` ### Turbo Morphing (Page Refresh) - PREFERRED **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) ```erb <%# app/views/layouts/application.html.erb %> <%= content_for?(:title) ? yield(:title) : "App" %> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> <%# Enable Turbo Morph for page refreshes %> <%= yield %> ``` **That's it!** Standard Rails controllers now work with morphing. No custom JavaScript needed. **Reference:** [Turbo Page Refreshes Documentation](https://turbo.hotwired.dev/handbook/page_refreshes) Standard Rails CRUD works automatically with Turbo Morph **Controller (stock Rails scaffold):** ```ruby 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):** ```erb <%# app/views/feedbacks/index.html.erb %>

Feedbacks

<%= link_to "New Feedback", new_feedback_path, class: "btn btn-primary" %>
<% @feedbacks.each do |feedback| %> <%= render feedback %> <% end %>
<%# app/views/feedbacks/_feedback.html.erb %>

<%= feedback.content %>

<%= 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?" } } %>
``` **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 ```erb <%# Flash messages persist during morphing %>
<% flash.each do |type, message| %>
<%= message %>
<% end %>
<%# Video/audio won't restart on page morph %> <%# 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) ```ruby # Model broadcasts page refresh to all subscribers (Rails 8+) class Feedback < ApplicationRecord broadcasts_refreshes end ``` ```erb <%# View subscribes to stream - morphs when model changes %> <%= turbo_stream_from @feedback %>
<% @feedbacks.each do |feedback| %> <%= render feedback %> <% end %>
``` **What happens:** User A creates feedback → server broadcasts `` → 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](https://turbo.hotwired.dev/handbook/page_refreshes#broadcasting-page-refreshes)
Use method="morph" in Turbo Streams for intelligent updates ```ruby # 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 ``` ```erb <%# Or in .turbo_stream.erb view %> ``` **Difference:** `method: :morph` preserves form state and focus. Without it, content is fully replaced. Using Turbo Frames for simple CRUD lists Turbo Morph is simpler and preserves more state. Frames are overkill for basic updates. ```erb <%# ❌ BAD - Unnecessary Turbo Frame complexity %> <% @feedbacks.each do |feedback| %> <%= turbo_frame_tag dom_id(feedback) do %> <%= render feedback %> <% end %> <% end %> ``` ```erb <%# ✅ GOOD - Simple rendering, Turbo Morph handles updates %> <% @feedbacks.each do |feedback| %> <%= render feedback %> <% end %> ``` ### Turbo Frames - Use Sparingly **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) ```erb <%# Show view with inline edit frame %> <%= turbo_frame_tag dom_id(@feedback) do %>

<%= @feedback.content %>

<%= 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 ```erb <%# Lazy load stats when scrolled into view %> <%= turbo_frame_tag "statistics", src: statistics_path, loading: :lazy do %>

Loading statistics...

<% end %> <%# Frame that reloads with morphing on page refresh %> <%= turbo_frame_tag "live-stats", src: live_stats_path, refresh: "morph" do %>

Loading live statistics...

<% end %> ``` ```ruby # 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](https://turbo.hotwired.dev/handbook/page_refreshes#turbo-frames)
### Turbo Streams Seven Turbo Stream actions for dynamic updates ```ruby 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. Real-time updates via ActionCable with Turbo Streams ```ruby # 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 ``` ```erb <%# View subscribes to stream %> <%= turbo_stream_from "feedbacks" %>
<%= render @feedbacks %>
```
--- ## Hotwire Stimulus 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:** - Client-side interactions (dropdowns, tooltips, character counters) - Form enhancements (dynamic fields, auto-save) - UI behavior (modals, tabs, accordions) **Don't use Stimulus for:** - Basic CRUD operations (use Turbo Morph) - Simple list updates (use Turbo Morph) - Navigation (use Turbo Drive) ### Core Concepts Simple Stimulus controller with targets, actions, and values **Controller:** ```javascript // 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:** ```erb
0 / 1000
``` **Syntax:** `event->controller#method` (default event based on element type)
Typed data attributes for controller configuration ```javascript // 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) } } ``` ```erb
60
``` **Types:** Array, Boolean, Number, Object, String
Reference and communicate with other controllers ```javascript // 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 } } ``` ```erb
```
Dynamic add/remove nested fields using Stimulus **Form:** ```erb
<%= form_with model: @feedback do |form| %>
<%= form.fields_for :attachments do |f| %> <%= render "attachment_fields", form: f %> <% end %>
<% end %>
``` **Stimulus Controller:** ```javascript // 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 ```javascript // ❌ BAD - Memory leak connect() { this.timer = setInterval(() => this.update(), 1000) } ``` ```javascript // ✅ GOOD - Clean up disconnect() { clearInterval(this.timer) } ``` --- **System Tests for Turbo and Stimulus:** ```ruby # 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:** - Test with JavaScript disabled (progressive enhancement) - Verify scroll position preservation with Turbo Morph - Check focus management in modals and inline editing - Test real-time updates in multiple browser tabs --- - rails-ai:views - Partials, helpers, forms, and view structure - rails-ai:styling - Tailwind/DaisyUI for styling Hotwire components - rails-ai:controllers - RESTful actions that work with Turbo - rails-ai:testing - System tests for Turbo and Stimulus **Official Documentation:** - [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction) - [Turbo Page Refreshes (Morph)](https://turbo.hotwired.dev/handbook/page_refreshes) - [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction) **Community Resources:** - [Hotwire Discussion Forum](https://discuss.hotwired.dev/)