Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
You are an expert in Hotwire (HTML Over The Wire) and Turbo for building fast, reactive Rails applications with minimal custom JavaScript.
## Key Points
- Return `status: :unprocessable_entity` (422) from failed form submissions so Turbo replaces the frame with validation errors.
- Use `status: :see_other` (303) for redirects after non-GET requests.
- Keep Turbo Frames small and focused; one frame per editable unit.
- Use `turbo_stream` responses for actions that affect multiple parts of the page.
- Leverage broadcasting for real-time features but keep broadcast payloads minimal.
- Use `loading: :lazy` on frames to defer non-critical content.
- Set `data-turbo-action="advance"` on frames that should update the browser URL.
- **Missing status codes**: Turbo silently fails if a form error response returns 200 instead of 422.
- **Frame ID mismatch**: The frame tag ID must match between the source page and the response, or Turbo will not find the content to swap.
- **Forgetting `see_other` on redirects**: POST/PATCH/DELETE redirects must use 303 so Turbo issues a GET for the redirect target.
- **Overly large frames**: Wrapping the entire page in one frame defeats the purpose. Scope frames tightly.
- **Not handling the non-JS fallback**: Always provide `format.html` responses alongside `format.turbo_stream` for progressive enhancement.
## Quick Example
```erb
<%# Loads content asynchronously after the page renders %>
<%= turbo_frame_tag "weather_widget", src: weather_path, loading: :lazy do %>
<p>Loading weather...</p>
<% end %>
```
```erb
<%# Target the whole page from inside a frame %>
<%= link_to "View Full Post", post_path(post), data: { turbo_frame: "_top" } %>
```skilldb get ruby-rails-skills/Hotwire TurboFull skill: 244 linesHotwire and Turbo — Ruby on Rails
You are an expert in Hotwire (HTML Over The Wire) and Turbo for building fast, reactive Rails applications with minimal custom JavaScript.
Overview
Hotwire is the default frontend approach in modern Rails. It consists of Turbo (Drive, Frames, Streams) and Stimulus. Turbo replaces full page reloads with targeted HTML updates sent from the server, giving SPA-like responsiveness while keeping logic server-side.
Core Philosophy
Hotwire represents a fundamental bet: that most web application interactivity can be achieved by sending HTML from the server rather than JSON to a JavaScript framework. Instead of duplicating rendering logic in both the backend and frontend, you write it once in your Rails views and let Turbo handle the delivery. The result is less code, fewer abstractions, and a drastically simpler mental model for building interactive applications.
The key insight behind Turbo is that the unit of update is not a data payload — it is a piece of rendered HTML. Turbo Drive replaces full page loads, Turbo Frames scope updates to specific regions, and Turbo Streams allow the server to surgically mutate any part of the DOM. Each layer adds precision without requiring you to write JavaScript. The progressive escalation from Drive to Frames to Streams means you reach for more complexity only when simpler tools are insufficient.
This approach works because the server is the source of truth. There is no client-side state to synchronize, no optimistic update logic to reconcile, and no API versioning to manage between your own frontend and backend. When the HTML arrives, it is already correct. The tradeoff is latency on every interaction, which is why Turbo is designed to make those round-trips as fast and invisible as possible.
Anti-Patterns
-
Status Code Neglect: Returning 200 OK for failed form submissions instead of 422 Unprocessable Entity. Turbo relies on HTTP status codes to decide how to handle responses. A 200 on a validation failure causes Turbo to silently replace content without showing errors.
-
Mega-Frame Wrapping: Wrapping the entire page content in a single Turbo Frame. This defeats the purpose of frames, which are meant to scope updates to small, independent regions. If everything is one frame, you have just recreated full-page replacement with extra steps.
-
Ignoring Progressive Enhancement: Building Turbo Stream responses without a corresponding
format.htmlfallback. When JavaScript fails, or the user opens a link in a new tab, the HTML response must stand on its own. -
Broadcast Flooding: Using
after_create_commitbroadcasts to send large HTML partials to hundreds of connected clients on every database write. Keep broadcast payloads minimal and consider whether the real-time update is truly necessary for every subscriber. -
Fighting the Server Round-Trip: Adding complex client-side JavaScript to avoid server interactions that Turbo is designed to handle. If you find yourself building significant JavaScript state management alongside Turbo, you may be better served by committing fully to either Hotwire or a JavaScript framework — not both.
Core Concepts
Turbo Drive
Turbo Drive intercepts link clicks and form submissions, replacing full page loads with fetch requests and swapping the <body> content.
<%# Turbo Drive is enabled by default. Disable for specific links: %>
<%= link_to "External", "https://example.com", data: { turbo: false } %>
<%# Disable for a form: %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
...
<% end %>
Turbo Frames
Turbo Frames scope navigation to a specific region of the page. Only the matching frame is replaced.
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts_list" do %>
<% @posts.each do |post| %>
<%= render post %>
<% end %>
<%= link_to "Load more", posts_path(page: @next_page) %>
<% end %>
<%# app/views/posts/_post.html.erb %>
<%= turbo_frame_tag dom_id(post) do %>
<h2><%= link_to post.title, post_path(post) %></h2>
<p><%= post.summary %></p>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
Lazy-Loaded Frames
<%# Loads content asynchronously after the page renders %>
<%= turbo_frame_tag "weather_widget", src: weather_path, loading: :lazy do %>
<p>Loading weather...</p>
<% end %>
Breaking Out of Frames
<%# Target the whole page from inside a frame %>
<%= link_to "View Full Post", post_path(post), data: { turbo_frame: "_top" } %>
Turbo Streams
Turbo Streams allow the server to send targeted DOM mutations (append, prepend, replace, update, remove, before, after).
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@comment = @post.comments.build(comment_params)
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
else
render :new, status: :unprocessable_entity
end
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments" do %>
<%= render @comment %>
<% end %>
<%= turbo_stream.update "comment_count" do %>
<%= @post.comments.count %> comments
<% end %>
<%= turbo_stream.replace "new_comment_form" do %>
<%= render "comments/form", comment: Comment.new %>
<% end %>
Broadcasting with Turbo Streams (WebSockets)
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :room
after_create_commit -> { broadcast_append_to room, target: "messages" }
after_update_commit -> { broadcast_replace_to room }
after_destroy_commit -> { broadcast_remove_to room }
end
<%# app/views/rooms/show.html.erb %>
<%= turbo_stream_from @room %>
<div id="messages">
<%= render @room.messages %>
</div>
Implementation Patterns
Flash Messages with Turbo
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Turbo requires status codes for re-rendering
# 303 for redirects after non-GET requests
def redirect_with_flash(path, notice: nil, alert: nil)
redirect_to path, status: :see_other, notice: notice, alert: alert
end
end
# Important: Turbo expects specific HTTP status codes
def update
if @post.update(post_params)
redirect_to @post, notice: "Updated!", status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
Inline Editing Pattern
<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag dom_id(task) do %>
<div class="task">
<span><%= task.title %></span>
<%= link_to "Edit", edit_task_path(task) %>
</div>
<% end %>
<%# app/views/tasks/edit.html.erb %>
<%= turbo_frame_tag dom_id(@task) do %>
<%= form_with model: @task do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<%= link_to "Cancel", task_path(@task) %>
<% end %>
<% end %>
Search with Turbo Frames
<%# app/views/products/index.html.erb %>
<%= form_with url: products_path, method: :get,
data: { turbo_frame: "products_results", turbo_action: "advance" } do |f| %>
<%= f.search_field :q, value: params[:q], placeholder: "Search products..." %>
<%= f.submit "Search" %>
<% end %>
<%= turbo_frame_tag "products_results" do %>
<%= render @products %>
<%= paginate @products %>
<% end %>
Multi-Stream Responses
<%# app/views/orders/create.turbo_stream.erb %>
<%= turbo_stream.prepend "orders_list" do %>
<%= render @order %>
<% end %>
<%= turbo_stream.update "orders_count", plain: Order.count.to_s %>
<%= turbo_stream.append "notifications" do %>
<div class="flash flash-success" data-controller="auto-dismiss">
Order created successfully!
</div>
<% end %>
Best Practices
- Return
status: :unprocessable_entity(422) from failed form submissions so Turbo replaces the frame with validation errors. - Use
status: :see_other(303) for redirects after non-GET requests. - Keep Turbo Frames small and focused; one frame per editable unit.
- Use
turbo_streamresponses for actions that affect multiple parts of the page. - Leverage broadcasting for real-time features but keep broadcast payloads minimal.
- Use
loading: :lazyon frames to defer non-critical content. - Set
data-turbo-action="advance"on frames that should update the browser URL.
Common Pitfalls
- Missing status codes: Turbo silently fails if a form error response returns 200 instead of 422.
- Frame ID mismatch: The frame tag ID must match between the source page and the response, or Turbo will not find the content to swap.
- Forgetting
see_otheron redirects: POST/PATCH/DELETE redirects must use 303 so Turbo issues a GET for the redirect target. - Overly large frames: Wrapping the entire page in one frame defeats the purpose. Scope frames tightly.
- Not handling the non-JS fallback: Always provide
format.htmlresponses alongsideformat.turbo_streamfor progressive enhancement. - Broadcasting too much: Sending large HTML partials over WebSockets to many clients can strain the server. Keep broadcasts lean.
Install this skill directly: skilldb add ruby-rails-skills
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.
Stimulus
Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.