Back to list
bastos

hotwire

by bastos

Bastos' Claude Code Ruby Plugin Marketplace

0🍴 0📅 Jan 24, 2026

SKILL.md


name: hotwire description: This skill should be used when the user asks about "Hotwire", "Turbo", "Turbo Drive", "Turbo Frames", "Turbo Streams", "Stimulus", "Stimulus controllers", "Stimulus values", "Stimulus targets", "Stimulus actions", "import maps", "live updates", "partial page updates", "SPA-like behavior", "real-time updates", "turbo_frame_tag", "turbo_stream", "broadcast", or needs guidance on building modern Rails 7+ frontends without heavy JavaScript frameworks. version: 1.1.0

Hotwire for Rails 7+

Comprehensive guide to building modern, reactive web applications with Hotwire (Turbo + Stimulus) in Rails 7+.

What is Hotwire?

Hotwire enables "fast, modern, progressively enhanced web applications without using much JavaScript." It sends HTML over the wire instead of JSON, keeping business logic on the server.

Three components:

  1. Turbo Drive - Accelerates navigation by intercepting clicks and replacing <body>
  2. Turbo Frames - Decompose pages into independently-updatable sections
  3. Turbo Streams - Deliver live partial page updates via WebSocket, SSE, or HTTP
  4. Stimulus - Modest JavaScript framework for behavior

Turbo Drive

Turbo Drive intercepts link clicks and form submissions, fetching via fetch and replacing <body> while merging <head>. The window, document, and <html> persist across navigations.

Disabling for Specific Elements

<%# Disable Turbo for external links or file downloads %>
<%= link_to "Download PDF", pdf_path, data: { turbo: false } %>

<%# Disable for forms that need full page reload %>
<%= form_with model: @export, data: { turbo: false } do |f| %>

Progress Bar

.turbo-progress-bar {
  background-color: #3b82f6;
  height: 3px;
}

Turbo Frames

Frames wrap page segments in <turbo-frame> elements for scoped navigation. Only matching frames extract from server responses.

Frame Attributes

AttributePurpose
idRequired. Unique identifier to match frames between requests
srcURL for eager-loading frame content on page load
loading="lazy"Delay loading until frame becomes visible
targetNavigation scope: _top (full page), _self, or frame ID
data-turbo-actionPromote to browser history (advance or replace)

Basic Frame

<%# app/views/articles/show.html.erb %>
<h1><%= @article.title %></h1>

<%= turbo_frame_tag @article do %>
  <p><%= @article.body %></p>
  <%= link_to "Edit", edit_article_path(@article) %>
<% end %>

<%# app/views/articles/edit.html.erb %>
<%= turbo_frame_tag @article do %>
  <%= render "form", article: @article %>
<% end %>

Lazy Loading

<%# Load content when frame becomes visible %>
<%= turbo_frame_tag "comments",
      src: article_comments_path(@article),
      loading: :lazy do %>
  <p>Loading comments...</p>
<% end %>

Breaking Out of Frames

<%# Navigate entire page %>
<%= link_to "View Full", article_path(@article), data: { turbo_frame: "_top" } %>

<%# Target different frame %>
<%= link_to "Preview", preview_path(@article), data: { turbo_frame: "preview_panel" } %>

Frame State Attributes

During navigation, frames receive [aria-busy="true"]. After completion, [complete] is set on the frame.

Turbo Streams

Streams deliver DOM changes as <turbo-stream> elements specifying action and target.

Stream Actions (9 total)

ActionDescription
appendAdd content to end of target
prependAdd content to start of target
replaceReplace entire target element
updateReplace target's inner content only
removeDelete target element
beforeInsert before target element
afterInsert after target element
morphIntelligently morph changes (variant of replace/update)
refreshRefresh page or frame

HTTP Stream Response

# app/controllers/comments_controller.rb
def create
  @comment = @article.comments.build(comment_params)

  if @comment.save
    respond_to do |format|
      format.turbo_stream  # Renders create.turbo_stream.erb
      format.html { redirect_to @article }
    end
  else
    render :new, status: :unprocessable_entity
  end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_count", @article.comments.count %>
<%= turbo_stream.replace "new_comment", partial: "form", locals: { comment: Comment.new } %>

Inline Stream Response

def destroy
  @comment.destroy

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.remove(@comment),
        turbo_stream.update("comment_count", @article.comments.count)
      ]
    end
    format.html { redirect_to @article }
  end
end

Broadcasting via WebSocket

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :article

  after_create_commit -> {
    broadcast_append_to article, target: "comments"
  }

  after_destroy_commit -> {
    broadcast_remove_to article
  }
end
<%# Subscribe to broadcasts %>
<%= turbo_stream_from @article %>

<div id="comments">
  <%= render @article.comments %>
</div>

WebSocket/SSE Source

<%# Persistent connection for real-time updates %>
<turbo-stream-source src="ws://example.com/updates"></turbo-stream-source>

Stimulus

Stimulus is "a JavaScript framework with modest ambitions." It enhances server-rendered HTML using data attributes.

Controller Structure

// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["name", "output"]
  static values = { greeting: { type: String, default: "Hello" } }
  static classes = ["active"]

  connect() {
    // Called when controller connects to DOM
  }

  disconnect() {
    // Called when controller disconnects - clean up here
  }

  greet() {
    this.outputTarget.textContent = `${this.greetingValue}, ${this.nameTarget.value}!`
  }
}

Naming Conventions

ContextConventionExample
Controller filesnake_case or kebab-casehello_controller.js, date-picker_controller.js
Controller identifierkebab-case in HTMLdata-controller="date-picker"
TargetscamelCasestatic targets = ["firstName"]
ValuescamelCasestatic values = { itemCount: Number }
MethodscamelCasesubmitForm()

Targets

Define elements to reference within controller:

static targets = ["query", "results", "errorMessage"]

Generated properties:

PropertyPurpose
this.queryTargetFirst matching element (throws if missing)
this.queryTargetsArray of all matching elements
this.hasQueryTargetBoolean existence check

Target callbacks:

queryTargetConnected(element) {
  // Called when target is added to DOM
}

queryTargetDisconnected(element) {
  // Called when target is removed from DOM
}
<div data-controller="search">
  <input data-search-target="query">
  <div data-search-target="results"></div>
</div>

Values

Read/write typed data attributes:

static values = {
  index: Number,           // data-controller-index-value
  url: String,             // data-controller-url-value
  active: Boolean,         // data-controller-active-value
  items: Array,            // data-controller-items-value (JSON)
  config: Object           // data-controller-config-value (JSON)
}

Five supported types: Array, Boolean, Number, Object, String

Default values:

static values = {
  count: { type: Number, default: 0 },
  url: { type: String, default: "/api" }
}

Change callbacks:

countValueChanged(value, previousValue) {
  // Called on initialize and when value changes
  this.element.textContent = value
}

Actions

Connect DOM events to controller methods:

Format: event->controller#method

<button data-action="click->gallery#next">Next</button>

Event shorthand (common defaults):

  • <button>, <a>click
  • <form>submit
  • <input>, <textarea>input
  • <select>change
  • <details>toggle
<%# Equivalent to click->gallery#next %>
<button data-action="gallery#next">Next</button>

Global events:

<div data-action="resize@window->gallery#layout">
<div data-action="keydown@document->modal#close">

Keyboard filters:

<input data-action="keydown.enter->search#submit">
<input data-action="keydown.esc->modal#close">
<input data-action="keydown.ctrl+s->form#save">

Action options:

OptionEffect
:stopCalls stopPropagation()
:preventCalls preventDefault()
:selfOnly fires if target matches element
:captureUse capture phase
:onceRemove after first invocation
:passivePassive event listener
<form data-action="submit->form#save:prevent">
<a data-action="click->link#track:stop">

Action parameters:

<button data-action="item#delete"
        data-item-id-param="123"
        data-item-type-param="article">
delete(event) {
  const { id, type } = event.params
  // id = 123 (Number), type = "article" (String)
}

Classes

Define CSS class names as controller properties:

static classes = ["active", "loading"]
<div data-controller="toggle"
     data-toggle-active-class="bg-blue-500"
     data-toggle-loading-class="opacity-50">
this.activeClass     // "bg-blue-500"
this.hasActiveClass  // true
this.loadingClasses  // ["opacity-50"]

Import Maps (Rails 7+)

# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin_all_from "app/javascript/controllers", under: "controllers"
bin/importmap pin lodash
bin/importmap pin chartkick --from jsdelivr

Additional Resources

Reference Files

  • references/stimulus-patterns.md - Common Stimulus controller patterns
  • references/turbo-streams-advanced.md - Complex broadcasting scenarios

Score

Total Score

65/100

Based on repository quality metrics

SKILL.md

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

+20
LICENSE

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

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

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

0/5
Issue管理

オープンIssueが50未満

+5
言語

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

+5
タグ

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

+5

Reviews

💬

Reviews coming soon