Use when writing Hotwire (Turbo/Stimulus) code in Rails - enforces dom_id helpers, morph updates, focused Stimulus controllers, and JavaScript private methods
/plugin marketplace add ZempTime/zemptime-marketplace/plugin install vanilla-rails@zemptime-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
37signals conventions for Hotwire beyond the official documentation.
WRONG:
<%= turbo_stream.replace "card_#{@card.id}" do %>
RIGHT:
<%# Both syntaxes acceptable %>
<%= turbo_stream.replace dom_id(@card) do %>
<%= turbo_stream.replace [ @card ] do %>
Prefixed dom_id enables granular updates to specific parts:
dom_id(@card) # "card_abc123"
dom_id(@card, :header) # "header_card_abc123"
dom_id(@card, :comments) # "comments_card_abc123"
dom_id(@card, :status_badge) # "status_badge_card_abc123"
# Array syntax (Rails shorthand)
[ @card, :header ] # Same as dom_id(@card, :header)
Example:
<%= turbo_stream.replace dom_id(@card, :status_badge), method: :morph do %>
<%= render "cards/status_badge", card: @card %>
<% end %>
Morph avoids layout shift and preserves scroll position.
WRONG:
<%= turbo_stream.replace dom_id(@card) do %>
<%= render @card %>
<% end %>
RIGHT:
<%= turbo_stream.replace dom_id(@card), method: :morph do %>
<%= render @card %>
<% end %>
When to use morph:
When NOT to use morph:
append/prepend)remove)One purpose per controller. Split large controllers.
WRONG:
// card_controller.js - does too much
export default class extends Controller {
connect() { }
fadeIn() { }
handleClick() { }
validateForm() { }
submitForm() { }
showNotification() { }
}
RIGHT:
// status_animation_controller.js - focused
export default class extends Controller {
connect() {
this.#fadeIn()
}
#fadeIn() {
// Use CSS transitions, minimal JS
this.element.classList.add('fade-in')
}
}
Use JavaScript private fields syntax for methods/fields not called from HTML.
WRONG:
export default class extends Controller {
debounceTimer = null // Public field (shouldn't be)
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.showNotification() // Public method (shouldn't be)
}
showNotification() {
this.element.classList.add('success')
}
}
RIGHT:
export default class extends Controller {
#debounceTimer = null // Private field
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.#showNotification()
}
#showNotification() {
this.element.classList.add('success')
}
}
Ask yourself: "Is this method called from HTML via data-action?"
Public methods: Only those in data-action="controller#method" OR Stimulus lifecycle methods
Private methods: Everything else - helpers, callbacks, utilities
Example:
<!-- This means mouseEnter and mouseLeave are public -->
<div data-controller="preview"
data-action="mouseenter->preview#mouseEnter mouseleave->preview#mouseLeave">
export default class extends Controller {
// Public - called from data-action
mouseEnter() { this.#show() }
mouseLeave() { this.#hide() }
// Public - Stimulus lifecycle (framework calls these)
connect() { this.#initialize() }
disconnect() { this.#cleanup() }
// Private - only called internally
#initialize() { }
#cleanup() { }
#show() { }
#hide() { }
#fetch() { }
}
Stimulus lifecycle methods (always public, no #):
connect(), disconnect()[name]TargetConnected(), [name]TargetDisconnected()[name]ValueChanged()If you write any of these without #, STOP:
show, hide, toggle, clear, reset, updatefetch, load, save, submitconnect() or other methodsdata-actionCheck: Search your HTML for data-action. If the method isn't there, add #.
Controllers coordinate UI behavior only. No data transformations, validations, or domain logic.
WRONG:
export default class extends Controller {
submit() {
// Don't validate/transform data in JS
if (this.priceValue < 0) {
this.priceValue = 0
}
this.element.submit()
}
}
RIGHT:
export default class extends Controller {
submit() {
// Just coordinate the UI
this.element.submit()
}
}
Let Rails controllers and models handle business logic.
Structure partials with prefixed dom_id for targetable sections:
<%# app/views/cards/_card.html.erb %>
<article id="<%= dom_id(card) %>" class="card">
<div id="<%= dom_id(card, :status) %>">
<%= render "cards/status", card: card %>
</div>
<div id="<%= dom_id(card, :header) %>">
<%= render "cards/header", card: card %>
</div>
<div id="<%= dom_id(card, :comments) %>">
<%= render "cards/comments", card: card %>
</div>
</article>
This enables targeted updates:
<%# app/views/cards/closures/create.turbo_stream.erb %>
<%= turbo_stream.replace dom_id(@card, :status), method: :morph do %>
<%= render "cards/status", card: @card %>
<% end %>
| Violation | Fix |
|---|---|
"card_#{@card.id}" | dom_id(@card) or [ @card ] |
turbo_stream.replace dom_id(@card) | turbo_stream.replace dom_id(@card), method: :morph |
fadeIn() { } | #fadeIn() { } |
debounceTimer = null | #debounceTimer = null |
| Animation logic in Stimulus | Use CSS transitions, minimal JS |
| One controller doing many things | Split into focused controllers |
| Validations in Stimulus | Move to Rails models/controllers |
| Helper methods without # | Add # to all helpers not in data-action |
Turbo Stream with morph:
<%= turbo_stream.replace dom_id(@record, :section), method: :morph do %>
<%= render "partial", record: @record %>
<% end %>
Stimulus with private methods and fields:
export default class extends Controller {
#privateField = null
publicAction() {
this.#privateHelper()
}
#privateHelper() {
// Implementation
}
}
View containers:
<div id="<%= dom_id(record, :prefix) %>">
<%= render "partial", record: record %>
</div>
Master authentication and authorization patterns including JWT, OAuth2, session management, and RBAC to build secure, scalable access control systems. Use when implementing auth systems, securing APIs, or debugging security issues.