Hotwire (HTML Over The Wire) is Rails' answer to frontend complexity. Instead of shipping JSON to a heavy JavaScript framework, Hotwire delivers HTML directly from the server.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
examples/hotwire-patterns.jsreferences/stimulus-controllers.mdreferences/turbo-frames.mdreferences/turbo-streams.mdHotwire (HTML Over The Wire) is Rails' answer to frontend complexity. Instead of shipping JSON to a heavy JavaScript framework, Hotwire delivers HTML directly from the server.
Hotwire consists of:
Together, they provide rich, reactive UIs with minimal JavaScript and no build step.
Hotwire reflects Rails 8's core principles:
Most applications need less JavaScript than you think. Hotwire proves it.
Turbo Drive intercepts link clicks and form submissions, replacing full page loads with AJAX requests that update the page content.
Without Turbo Drive:
Click link → Browser requests page → Full page reload → JavaScript re-initializes
With Turbo Drive:
Click link → AJAX request → Replace <body> → Fast transition
Benefits:
Automatically enabled when you include Turbo:
// app/javascript/application.js
import "@hotwired/turbo-rails"
Now all links and forms use Turbo Drive automatically:
<%= link_to "Products", products_path %>
<!-- Navigates via Turbo Drive -->
<%= form_with model: @product do |f| %>
<!-- Submits via Turbo Drive -->
<% end %>
For specific links/forms:
<%= link_to "External", "https://example.com", data: { turbo: false } %>
<%= form_with model: @product, data: { turbo: false } do |f| %>
<!-- Regular form submission -->
<% end %>
Built-in progress indicator for navigation:
/* Customize progress bar */
.turbo-progress-bar {
height: 5px;
background-color: #0076ff;
}
Turbo Frames let you update specific page sections without affecting the rest of the page.
Traditional approach:
Update product → Full page reload → Entire page re-renders
With Turbo Frames:
Update product → Only product frame updates → Rest of page untouched
<!-- app/views/products/index.html.erb -->
<h1>Products</h1>
<%= turbo_frame_tag "new_product" do %>
<%= link_to "New Product", new_product_path %>
<% end %>
<div id="products">
<%= render @products %>
</div>
<!-- app/views/products/new.html.erb -->
<%= turbo_frame_tag "new_product" do %>
<h2>New Product</h2>
<%= form_with model: @product do |f| %>
<%= f.text_field :name %>
<%= f.submit %>
<% end %>
<% end %>
When clicking "New Product", only the new_product frame updates—the rest of the page stays.
Rails provides dom_id for consistent frame IDs:
<%= turbo_frame_tag dom_id(@product) do %>
<%= render @product %>
<% end %>
<!-- Generates: <turbo-frame id="product_123">...</turbo-frame> -->
<!-- Clicking links inside frame navigates the frame -->
<%= turbo_frame_tag "products" do %>
<% @products.each do |product| %>
<%= link_to product.name, product_path(product) %>
<!-- Navigates the frame, not the page -->
<% end %>
<% end %>
Navigate the full page from within a frame:
<%= link_to "View All", products_path, data: { turbo_frame: "_top" } %>
<!-- data-turbo-frame="_top" navigates the whole page -->
Load content on demand:
<%= turbo_frame_tag "lazy_content", src: lazy_products_path do %>
Loading...
<% end %>
<!-- Automatically loads when frame appears in viewport -->
See references/turbo-frames.md for advanced patterns.
Turbo Streams deliver targeted HTML updates after form submissions or via WebSockets.
Seven Stream Actions:
# app/controllers/products_controller.rb
def create
@product = Product.new(product_params)
respond_to do |format|
if @product.save
format.turbo_stream { render turbo_stream: turbo_stream.prepend("products", @product) }
format.html { redirect_to @product }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@product), partial: "form", locals: { product: @product }) }
format.html { render :new, status: :unprocessable_entity }
end
end
end
<!-- app/views/products/index.html.erb -->
<div id="products">
<%= render @products %>
</div>
<%= turbo_frame_tag "new_product" do %>
<%= render "form", product: @product %>
<% end %>
When form submits, new product prepends to #products list without page reload.
Stream changes to all connected users:
# app/models/product.rb
class Product < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "products", target: "products" }
after_update_commit -> { broadcast_replace_to "products" }
after_destroy_commit -> { broadcast_remove_to "products" }
end
<!-- app/views/products/index.html.erb -->
<%= turbo_stream_from "products" %>
<div id="products">
<%= render @products %>
</div>
Now when any user creates/updates/deletes a product, all connected users see the change instantly.
# app/views/products/create.turbo_stream.erb
<%= turbo_stream.prepend "products", @product %>
<%= turbo_stream.update "counter", Product.count %>
<%= turbo_stream.replace "flash", partial: "shared/flash" %>
See references/turbo-streams.md for broadcasting patterns.
Stimulus is a modest JavaScript framework for adding behavior to HTML.
Philosophy:
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
toggle() {
this.menuTarget.classList.toggle("hidden")
}
}
<!-- app/views/shared/_header.html.erb -->
<div data-controller="dropdown">
<button data-action="click->dropdown#toggle">Menu</button>
<nav data-dropdown-target="menu" class="hidden">
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
</div>
Stimulus uses three data attributes:
Reference elements in controllers:
export default class extends Controller {
static targets = ["input", "output", "button"]
connect() {
console.log(this.inputTarget) // First matching element
console.log(this.inputTargets) // All matching elements
console.log(this.hasInputTarget) // Boolean check
}
}
<div data-controller="example">
<input data-example-target="input">
<input data-example-target="input">
<div data-example-target="output"></div>
<button data-example-target="button">Click</button>
</div>
Connect events to methods:
<!-- Default event (click for buttons/links, input for form fields) -->
<button data-action="dropdown#toggle">Toggle</button>
<!-- Explicit event -->
<input data-action="keyup->search#query">
<!-- Multiple actions -->
<form data-action="submit->form#submit ajax:success->form#success">
<!-- Action options -->
<button data-action="click->menu#open:prevent">
<!-- :prevent calls preventDefault() -->
</button>
Pass data to controllers:
export default class extends Controller {
static values = {
url: String,
count: Number,
active: Boolean
}
connect() {
console.log(this.urlValue)
console.log(this.countValue)
console.log(this.activeValue)
}
urlValueChanged(value, previousValue) {
// Called when value changes
}
}
<div data-controller="example"
data-example-url-value="<%= products_path %>"
data-example-count-value="5"
data-example-active-value="true">
</div>
See references/stimulus-controllers.md for controller patterns.
<div data-controller="auto-refresh" data-auto-refresh-interval-value="5000">
<%= turbo_frame_tag "stats", src: stats_path do %>
Loading...
<% end %>
</div>
// auto_refresh_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { interval: Number }
static targets = ["frame"]
connect() {
this.startRefreshing()
}
disconnect() {
this.stopRefreshing()
}
startRefreshing() {
this.refreshTimer = setInterval(() => {
this.element.querySelector('turbo-frame').reload()
}, this.intervalValue)
}
stopRefreshing() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
}
}
}
<%= turbo_frame_tag dom_id(product) do %>
<div data-controller="inline-edit">
<span data-inline-edit-target="display">
<%= product.name %>
</span>
<button data-action="inline-edit#edit">Edit</button>
</div>
<% end %>
<%= turbo_frame_tag "modal" %>
<%= link_to "New Product", new_product_path, data: { turbo_frame: "modal" } %>
Form renders inside modal frame.
<div data-controller="infinite-scroll" data-action="scroll->infinite-scroll#loadMore">
<%= turbo_frame_tag "products", src: products_path(page: 1) %>
</div>
<%= form_with url: search_products_path, method: :get, data: { turbo_frame: "results", turbo_action: "advance" } do |f| %>
<%= f.search_field :q, data: { action: "input->debounce#search" } %>
<% end %>
<%= turbo_frame_tag "results" do %>
<!-- Search results render here -->
<% end %>
For deeper exploration:
references/turbo-frames.md: Complete Turbo Frames guide with patternsreferences/turbo-streams.md: Broadcasting and real-time updatesreferences/stimulus-controllers.md: Building Stimulus controllersFor code examples:
examples/hotwire-patterns.js: Common Hotwire patternsHotwire provides:
Master Hotwire and build rich, interactive applications with minimal JavaScript.