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

ActionEffectMethod
appendAdd after last childturbo_stream.append
prependAdd before first childturbo_stream.prepend
replaceReplace whole elementturbo_stream.replace
updateReplace inner HTMLturbo_stream.update
removeDelete elementturbo_stream.remove
beforeInsert before elementturbo_stream.before
afterInsert after elementturbo_stream.after