Rails Turbo/Hotwire Guide
Build fast, SPA-like Rails apps without heavy JavaScript: Turbo Drive for navigation, Turbo Frames for partial updates, Turbo Streams for real-time, and Stimulus for behavior.
1. Turbo Drive
<!-- Turbo Drive intercepts all clicks and form submissions
replacing only <body> and merging <head> -->
<!-- Opt-out specific link -->
<a href="/download" data-turbo="false">Download PDF</a>
<!-- Cache hints -->
<meta name="turbo-cache-control" content="no-cache">
<meta name="turbo-cache-control" content="no-preview">
<!-- Visit with specific action -->
<a href="/articles" data-turbo-action="replace">Replace history</a>
<a href="/articles" data-turbo-action="advance">Push to history</a>
# Controller — redirect after create
class ArticlesController < ApplicationController
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created!"
else
render :new, status: :unprocessable_entity
end
end
end
2. Turbo Frames
<!-- Wrap a section in a turbo-frame -->
<turbo-frame id="article_form">
<%= render "form", article: @article %>
</turbo-frame>
<!-- Link targets the frame — only frame content replaces -->
<turbo-frame id="article_preview">
<%= link_to "Preview", preview_article_path(@article) %>
</turbo-frame>
<!-- Lazy-load frame -->
<turbo-frame id="sidebar_stats" src="/stats/sidebar" loading="lazy">
<p>Loading stats...</p>
</turbo-frame>
<!-- Controller must respond with matching frame -->
# app/views/stats/sidebar.html.erb
<turbo-frame id="sidebar_stats">
<ul><% @stats.each do |s| %><li><%= s %></li><% end %></ul>
</turbo-frame>
3. Turbo Streams
# Controller — respond to Turbo Stream format
class CommentsController < ApplicationController
def create
@comment = @article.comments.build(comment_params.merge(user: current_user))
if @comment.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("comments", partial: "comment", locals: { comment: @comment }),
turbo_stream.replace("comment_count", partial: "comment_count", locals: { count: @article.comments.count }),
turbo_stream.update("comment_form", partial: "form", locals: { comment: Comment.new }),
]
end
format.html { redirect_to @article }
end
else
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("comment_form", partial: "form", locals: { comment: @comment }) }
format.html { render "articles/show", status: :unprocessable_entity }
end
end
end
end
<!-- Turbo Stream actions -->
<!-- turbo_stream.append — add after last child -->
<!-- turbo_stream.prepend — add before first child -->
<!-- turbo_stream.replace — replace element -->
<!-- turbo_stream.update — replace inner HTML -->
<!-- turbo_stream.remove — remove element -->
<!-- turbo_stream.before — insert before -->
<!-- turbo_stream.after — insert after -->
4. ActionCable + Turbo Streams (Real-time)
# Broadcast from model
class Comment < ApplicationRecord
after_create_commit -> { broadcast_prepend_to article }
after_destroy_commit -> { broadcast_remove_to article }
# Or with custom partial
after_update_commit do
broadcast_replace_to article, partial: "comments/comment", locals: { comment: self }
end
end
<!-- Subscribe to stream in view -->
<%= turbo_stream_from @article %>
<div id="comments">
<%= render @article.comments %>
</div>
5. Stimulus Controllers
// app/javascript/controllers/character_count_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "count"]
static values = { max: { type: Number, default: 280 } }
connect() {
this.update()
}
update() {
const remaining = this.maxValue - this.inputTarget.value.length
this.countTarget.textContent = remaining
this.countTarget.classList.toggle("text-danger", remaining < 0)
}
}
<!-- HTML -->
<div data-controller="character-count" data-character-count-max-value="280">
<textarea data-character-count-target="input" data-action="input->character-count#update"></textarea>
<span data-character-count-target="count"></span> remaining
</div>
6. Turbo Stream Actions Reference
| Action | Effect | Method |
|---|---|---|
| append | Add after last child | turbo_stream.append |
| prepend | Add before first child | turbo_stream.prepend |
| replace | Replace whole element | turbo_stream.replace |
| update | Replace inner HTML | turbo_stream.update |
| remove | Delete element | turbo_stream.remove |
| before | Insert before element | turbo_stream.before |
| after | Insert after element | turbo_stream.after |