home / skills / kaakati / rails-enterprise-dev / hotwire-patterns

This skill guides you through Turbo and Hotwire patterns to implement partial updates, real-time features, and Stimulus controllers in Rails apps.

npx playbooks add skill kaakati/rails-enterprise-dev --skill hotwire-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (8)
SKILL.md
7.3 KB
---
name: "Turbo & Hotwire Patterns"
description: "Complete guide to Hotwire implementation including Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus controllers in Rails applications. Use when: (1) Implementing partial page updates, (2) Adding real-time features, (3) Creating Turbo Frames and Streams, (4) Writing Stimulus controllers, (5) Debugging Turbo-related issues. Trigger keywords: Turbo, Stimulus, Hotwire, real-time, SPA, live updates, ActionCable, broadcasts, turbo_stream, turbo_frame"
version: 1.1.0
---

# Turbo & Hotwire Patterns

## Hotwire Decision Tree

```
What do I need?
│
├─ Full page navigation without reload?
│   └─ Turbo Drive (automatic, no config needed)
│
├─ Update part of page on interaction?
│   └─ Turbo Frames
│       └─ Wrap section in turbo_frame_tag
│
├─ Real-time updates from server?
│   └─ Turbo Streams + ActionCable
│       └─ Model broadcasts + turbo_stream_from
│
├─ Multiple DOM changes on form submit?
│   └─ Turbo Stream responses
│       └─ respond_to format.turbo_stream
│
├─ JavaScript behavior (click, input, etc)?
│   └─ Stimulus controller
│       └─ data-controller + data-action
│
└─ Communication between controllers?
    └─ Stimulus Outlets
        └─ static outlets + data-*-outlet
```

---

## NEVER Do This

**NEVER** forget matching frame IDs:
```erb
<%# WRONG - IDs don't match, frame won't update %>
<%= turbo_frame_tag "tasks" do %>
  <%= link_to "Edit", edit_task_path(@task) %>
<% end %>

<%# edit.html.erb - different ID %>
<%= turbo_frame_tag "task_edit" do %>
  <%= render "form" %>
<% end %>

<%# RIGHT - IDs match %>
<%= turbo_frame_tag dom_id(@task) do %>
  <%= link_to "Edit", edit_task_path(@task) %>
<% end %>

<%# edit.html.erb - same ID %>
<%= turbo_frame_tag dom_id(@task) do %>
  <%= render "form" %>
<% end %>
```

**NEVER** return HTML for form errors (return 422):
```ruby
# WRONG - 200 status doesn't show errors properly
format.turbo_stream {
  render turbo_stream: turbo_stream.replace("form", partial: "form")
}

# RIGHT - 422 status for validation errors
format.turbo_stream {
  render turbo_stream: turbo_stream.replace("form", partial: "form"),
         status: :unprocessable_entity
}
```

**NEVER** use target without checking existence:
```javascript
// WRONG - crashes if target missing
search() {
  this.resultsTarget.innerHTML = html
}

// RIGHT - check first
search() {
  if (this.hasResultsTarget) {
    this.resultsTarget.innerHTML = html
  }
}
```

**NEVER** forget cleanup in disconnect:
```javascript
// WRONG - memory leak
connect() {
  this.timer = setInterval(() => this.refresh(), 1000)
}

// RIGHT - clean up
connect() {
  this.timer = setInterval(() => this.refresh(), 1000)
}

disconnect() {
  clearInterval(this.timer)
}
```

**NEVER** skip ARIA for dynamic content:
```erb
<%# WRONG - screen readers miss updates %>
<div id="tasks">
  <%= render @tasks %>
</div>

<%# RIGHT - announce updates %>
<div id="tasks" aria-live="polite">
  <%= render @tasks %>
</div>
```

---

## Hotwire Stack Overview

```
Hotwire
├── Turbo
│   ├── Turbo Drive      — Full page navigation without reload
│   ├── Turbo Frames     — Partial page updates
│   └── Turbo Streams    — Real-time updates over WebSocket/HTTP
│
└── Stimulus             — Lightweight JavaScript controllers
```

**External References:**
- Turbo: https://turbo.hotwired.dev/
- Stimulus: https://stimulus.hotwired.dev/

---

## Turbo Drive

Automatically converts links/forms to AJAX. Disable when needed:

```erb
<%# Skip Turbo Drive for this link %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>

<%# Skip for form %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
```

---

## Turbo Frames Quick Reference

| Pattern | Usage |
|---------|-------|
| Basic frame | `turbo_frame_tag "id"` |
| Model frame | `turbo_frame_tag dom_id(@task)` |
| Target other | `data: { turbo_frame: "other_id" }` |
| Break out | `data: { turbo_frame: "_top" }` |
| Lazy load | `src: path, loading: :lazy` |

```erb
<%# Lazy loading example %>
<%= turbo_frame_tag "comments",
                    src: task_comments_path(@task),
                    loading: :lazy do %>
  <p>Loading comments...</p>
<% end %>
```

---

## Turbo Streams Quick Reference

| Action | Result |
|--------|--------|
| `append` | Add to end of container |
| `prepend` | Add to start of container |
| `replace` | Replace entire element |
| `update` | Replace element's contents |
| `remove` | Delete element |
| `before/after` | Insert adjacent |

```erb
<%# Stream response %>
<%= turbo_stream.prepend "tasks" do %>
  <%= render @task %>
<% end %>

<%= turbo_stream.remove dom_id(@deleted_task) %>
```

### Model Broadcasts

```ruby
class Task < ApplicationRecord
  after_create_commit -> { broadcast_prepend_to "tasks" }
  after_update_commit -> { broadcast_replace_to "tasks" }
  after_destroy_commit -> { broadcast_remove_to "tasks" }
end
```

```erb
<%# Subscribe in view %>
<%= turbo_stream_from "tasks" %>
<div id="tasks"><%= render @tasks %></div>
```

---

## Stimulus Quick Reference

| Feature | Declaration | HTML |
|---------|-------------|------|
| Targets | `static targets = ["input"]` | `data-search-target="input"` |
| Values | `static values = { delay: Number }` | `data-search-delay-value="300"` |
| Classes | `static classes = ["active"]` | `data-search-active-class="bg-blue"` |
| Actions | - | `data-action="click->search#submit"` |
| Outlets | `static outlets = ["form"]` | `data-search-form-outlet="#form"` |

### Basic Controller

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results"]
  static values = { url: String }

  search() {
    fetch(`${this.urlValue}?q=${this.inputTarget.value}`)
      .then(r => r.text())
      .then(html => this.resultsTarget.innerHTML = html)
  }
}
```

```erb
<div data-controller="search"
     data-search-url-value="<%= search_path %>">
  <input data-search-target="input"
         data-action="input->search#search">
  <div data-search-target="results"></div>
</div>
```

---

## Action Modifiers

| Modifier | Effect |
|----------|--------|
| `:prevent` | `event.preventDefault()` |
| `:stop` | `event.stopPropagation()` |
| `.enter` | Only on Enter key |
| `.esc` | Only on Escape key |
| `.away` | Click outside element |

```erb
<form data-action="submit->form#save:prevent">
  <input data-action="keydown.enter->form#save:prevent">
</form>
```

---

## Debugging Checklist

| Issue | Check |
|-------|-------|
| Frame not updating | Frame IDs match? |
| Streams not working | `turbo_stream_from` subscription? |
| Actions not firing | data-action syntax correct? Controller registered? |
| Morphing issues | `data-turbo-permanent` on persistent elements? |

---

## References

Detailed patterns and examples in `references/`:
- `turbo-frames.md` - Frame patterns, lazy loading, navigation
- `turbo-streams.md` - Stream actions, broadcasts, form validation
- `stimulus-controllers.md` - Targets, values, actions, classes, outlets
- `common-patterns.md` - Infinite scroll, auto-submit, flash messages
- `accessibility.md` - ARIA, keyboard navigation, focus management
- `testing.md` - System tests, Stimulus controller tests
- `turbo8-native.md` - Turbo 8 morphing, native apps

Overview

This skill is a complete guide to implementing Hotwire in Rails apps, covering Turbo Drive, Turbo Frames, Turbo Streams, and Stimulus controllers. It focuses on practical patterns for partial page updates, real-time broadcasts, and lightweight JavaScript behaviors. Use it to standardize Hotwire usage across projects and avoid common pitfalls.

How this skill works

The guide explains when to use Turbo Drive for full-page navigation and when to scope updates with Turbo Frames. It shows how to wire Turbo Streams to ActionCable broadcasts for real-time updates and how to respond with turbo_stream formats on form submissions. Stimulus patterns demonstrate targets, values, actions, outlets, and lifecycle cleanup to keep client behavior predictable.

When to use it

  • Implement partial page updates without writing custom JS (Turbo Frames)
  • Add real-time features via Turbo Streams + ActionCable broadcasts
  • Return multi-element DOM changes after form submit with turbo_stream responses
  • Write Stimulus controllers for form interactions, search, and component behavior
  • Debug Turbo-related issues: frames not updating, streams not subscribing, or actions not firing

Best practices

  • Always match turbo_frame_tag IDs between trigger and response; prefer dom_id(model) for model-scoped frames
  • Return 422 status for form validation errors in turbo_stream responses to surface errors correctly
  • Guard target access in Stimulus controllers (use has*Target checks) and clean up timers/listeners in disconnect to avoid leaks
  • Add ARIA live regions for dynamic content (aria-live="polite") to support screen readers
  • Use data-turbo="false" to opt out of Turbo Drive for external links or full-page form flows

Example use cases

  • Inline editing: wrap record partials in model-scoped turbo_frame_tag(dom_id(model)) and render the form into the same frame
  • Live task list: broadcast create/update/destroy from model callbacks and subscribe with turbo_stream_from in the index view
  • Search UI: Stimulus controller fetches results and updates a results target; guard for missing targets and use debounce via values
  • Form validation: respond_to format.turbo_stream with turbo_stream.replace('form', partial: 'form'), status: :unprocessable_entity
  • Lazy-loaded comments: turbo_frame_tag with src and loading: :lazy to fetch content on demand

FAQ

Why is my turbo frame not updating?

Most common cause is mismatched frame IDs. Ensure the triggering link or frame and the response use the same turbo_frame_tag id (use dom_id for models).

Form errors don't show after submit, why?

Return a 422 status with the turbo_stream response for validation errors. Returning 200 can prevent Turbo from treating the response as an error state.