Back to list
ZempTime

vanilla-rails-hotwire

by ZempTime

1🍴 0📅 Jan 7, 2026

SKILL.md


name: vanilla-rails-hotwire description: Use when writing Hotwire (Turbo/Stimulus) code in Rails - enforces dom_id helpers, morph updates, focused Stimulus controllers, and JavaScript private methods

Vanilla Rails Hotwire

37signals conventions for Hotwire beyond the official documentation.

Turbo Streams

ALWAYS Use dom_id Helper (NEVER String Interpolation)

WRONG:

<%= turbo_stream.replace "card_#{@card.id}" do %>

RIGHT:

<%# Both syntaxes acceptable %>
<%= turbo_stream.replace dom_id(@card) do %>
<%= turbo_stream.replace [ @card ] do %>

Use Prefixes for Targetable Sections

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 %>

ALWAYS Use method: :morph for Updates

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:

  • Updating existing content (cards, comments, headers)
  • Replacing sections that users might be reading
  • Any update where layout shift would be jarring

When NOT to use morph:

  • Adding new items to lists (use append/prepend)
  • Removing items (use remove)
  • First-time rendering

Stimulus Controllers

Keep Controllers Small and Focused

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')
  }
}

ALWAYS Mark Private Methods and Fields with # Prefix

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')
  }
}

Decision Tree: Public vs Private

Ask yourself: "Is this method called from HTML via data-action?"

  • YES → Keep it public (no #)
  • NO → Make it private (#)

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()

Red Flags - Methods That Should Be Private

If you write any of these without #, STOP:

  • Helper methods: show, hide, toggle, clear, reset, update
  • Fetch/API methods: fetch, load, save, submit
  • Callback methods called only from connect() or other methods
  • Any method not referenced in HTML data-action

Check: Search your HTML for data-action. If the method isn't there, add #.

NO Business Logic in Stimulus

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.

View Organization

Container Pattern for Granular Updates

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 %>

Common Violations

ViolationFix
"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 StimulusUse CSS transitions, minimal JS
One controller doing many thingsSplit into focused controllers
Validations in StimulusMove to Rails models/controllers
Helper methods without #Add # to all helpers not in data-action

Quick Reference

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>

Score

Total Score

45/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

0/5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon