You’ve been there. A product manager asks for "a bit of interactivity" on a form. Six weeks later you’re neck-deep in a React app with 47 npm packages, a custom Webpack config, a GraphQL layer, a Redux store, and a separate deploy pipeline. The original form still doesn’t work right.
Hotwire is Basecamp’s answer to that problem. It ships with Rails by default since version 7, it’s production-proven on Hey.com and Basecamp itself, and it lets you build genuinely interactive UIs without ever leaving the server-side rendering model you already know. No virtual DOM. No client-side routing. No node_modules folder the size of a small asteroid.
This article walks through the full mental model, then builds something real: an inline-editing task list with live updates — the kind of thing that usually gets handed to a React dev.
What Hotwire Actually Is
Hotwire is an umbrella term for three libraries: Turbo, Stimulus, and Strada (mobile, ignore it for now). The two you’ll use every day are Turbo and Stimulus, and they solve different problems.
Turbo intercepts navigation and form submissions, fetches the response over HTTP, and swaps parts of the page without a full reload. It has three modes:
- Turbo Drive — the baseline. Every link click and form submit becomes a fetch + body swap. Zero configuration.
- Turbo Frames — decompose a page into independently updatable regions. A frame only replaces itself when targeted.
- Turbo Streams — server-pushed or response-embedded HTML operations (
append,prepend,replace,update,remove). The server literally sends<turbo-stream action="replace">tags and the client applies them.
Stimulus is a modest JavaScript framework. It attaches controllers to DOM elements via data-controller attributes. No templating, no reactivity system — just a clean way to sprinkle behavior onto server-rendered HTML.
The mental shift: the server is still the source of truth for HTML. Turbo ships that HTML efficiently. Stimulus wires up small behaviors that don’t need a server round-trip.
Setup
If you’re on Rails 7+, Hotwire is already in your Gemfile. Fresh app:
rails new taskboard --database=postgresql
cd taskboard
Check your Gemfile — you should see gem "turbo-rails" and gem "stimulus-rails". If not:
# Gemfile
gem "turbo-rails"
gem "stimulus-rails"
bundle install
rails turbo:install stimulus:install
For the asset pipeline this article uses Importmap (no Node, no build step). If you prefer esbuild or Vite, the Stimulus/Turbo JS side is identical — only the import mechanism differs.
Your app/javascript/application.js should look like this after install:
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"
And app/javascript/controllers/index.js:
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
That’s the entire frontend setup. No Webpack. No package.json. Ship it.
Turbo Drive — Free Performance
Turbo Drive is on by default the moment you include turbo-rails. Every <a> link and <form> submission is intercepted. The response HTML is fetched, the <body> is swapped, the <head> is merged, and the browser history is updated with pushState.
The result: page transitions feel instant because the browser doesn’t do a full parse/render cycle. CSS and JS stay loaded.
You don’t need to do anything. But you do need to be aware of one thing.
Gotcha: third-party scripts that expect DOMContentLoaded
With Turbo Drive, DOMContentLoaded only fires once — on the initial hard load. If you have a script (analytics, a chat widget, an old jQuery plugin) that initializes on DOMContentLoaded, it won’t re-initialize on navigation.
Fix:
// Listen for Turbo's equivalent of DOMContentLoaded
document.addEventListener("turbo:load", () => {
// your initialization code
})
Or better: wrap that third-party code in a Stimulus controller and let Stimulus handle lifecycle properly.
Turbo Frames — Partial Page Updates
Let’s build something. A task list where checking off a task updates the row without touching the rest of the page.
rails generate model Task title:string completed:boolean
rails db:migrate
rails generate controller Tasks index create update destroy
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.order(created_at: :desc)
end
def create
@task = Task.create!(task_params)
redirect_to tasks_path
end
def update
@task = Task.find(params[:id])
@task.update!(task_params)
redirect_to tasks_path
end
def destroy
Task.find(params[:id]).destroy
redirect_to tasks_path
end
private
def task_params
params.require(:task).permit(:title, :completed)
end
end
Now the view. The key is wrapping each task row in a <turbo-frame> with a unique ID.
<%# app/views/tasks/index.html.erb %>
<h2>Tasks</h2>
<%= turbo_frame_tag "new_task" do %>
<%= form_with model: Task.new, url: tasks_path do |f| %>
<%= f.text_field :title, placeholder: "New task..." %>
<%= f.submit "Add" %>
<% end %>
<% end %>
<div id="tasks">
<%= render @tasks %>
</div>
<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag dom_id(task) do %>
<div class="task-row">
<%= form_with model: task, url: task_path(task), method: :patch do |f| %>
<%= f.check_box :completed, onchange: "this.form.requestSubmit()" %>
<%= f.hidden_field :title %>
<% end %>
<span class="<%= 'done' if task.completed? %>"><%= task.title %></span>
<%= button_to "Delete", task_path(task), method: :delete,
data: { turbo_frame: dom_id(task) } %>
</div>
<% end %>
When the checkbox changes, the form submits. The controller redirects to tasks_path. Turbo fetches that page, finds the frame with the matching ID, and swaps only that frame. The rest of the page doesn’t move.
Gotcha: the redirect target must contain the matching frame
If your controller redirects to a page that doesn’t have <turbo-frame id="task_1"> in it, Turbo throws a frame-missing error in the console and nothing updates. Always redirect to a view that re-renders the relevant frame. redirect_to tasks_path works here because _task.html.erb is rendered in that response.
Turbo Streams — Surgical DOM Updates
Frames are great but they still load the full page and extract one section. Turbo Streams are more surgical. The server response is only the instructions, not a full page.
Let’s make the "Add task" form append the new task to the list without a redirect.
# app/controllers/tasks_controller.rb
def create
@task = Task.create!(task_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to tasks_path }
end
end
<%# app/views/tasks/create.turbo_stream.erb %>
<%= turbo_stream.prepend "tasks", partial: "task", locals: { task: @task } %>
<%= turbo_stream.replace "new_task" do %>
<%= render "form", task: Task.new %>
<% end %>
Two operations in one response:
- Prepend the new task partial to
#tasks. - Reset the form inside
#new_taskframe.
The browser applies both atomically. No redirect. No full page fetch. The response is maybe 200 bytes of HTML instructions.
Gotcha: format.turbo_stream must come before format.html
Rails picks the first matching format. If you flip them, Turbo requests will get the HTML redirect response and Drive will navigate away. Order matters.
Production note: For the delete action, return a stream that removes the element:
def destroy
@task = Task.find(params[:id]).tap(&:destroy)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@task) }
format.html { redirect_to tasks_path }
end
end
Stimulus — Behavior Without a Framework
Turbo handles navigation and updates. Stimulus handles client-side behavior that doesn’t need a server round-trip: toggling visibility, character counters, drag handles, keyboard shortcuts, clipboard operations.
Let’s add an inline-edit behavior. Click the task title → it becomes an input. Press Escape → cancel. Press Enter → submit.
Generate the controller:
rails generate stimulus inline-edit
This creates app/javascript/controllers/inline_edit_controller.js.
// app/javascript/controllers/inline_edit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// Targets declared here become this.titleTarget, this.formTarget, etc.
static targets = ["title", "form", "input"]
edit() {
this.titleTarget.hidden = true
this.formTarget.hidden = false
this.inputTarget.focus()
this.inputTarget.select()
}
cancel() {
this.titleTarget.hidden = false
this.formTarget.hidden = true
// restore the input to the original value
this.inputTarget.value = this.titleTarget.textContent.trim()
}
// Submit on Enter, cancel on Escape
handleKey(event) {
if (event.key === "Escape") this.cancel()
if (event.key === "Enter") {
event.preventDefault()
this.formTarget.requestSubmit()
}
}
}
Wire it into the partial:
<%# app/views/tasks/_task.html.erb %>
<%= turbo_frame_tag dom_id(task) do %>
<div class="task-row" data-controller="inline-edit">
<%= form_with model: task, url: task_path(task), method: :patch,
data: { "inline-edit-target": "form" },
style: "display:none" do |f| %>
<%= f.text_field :title,
data: {
"inline-edit-target": "input",
action: "keydown->inline-edit#handleKey"
} %>
<%= f.hidden_field :completed %>
<% end %>
<span data-inline-edit-target="title"
data-action="dblclick->inline-edit#edit">
<%= task.title %>
</span>
</div>
<% end %>
Double-click the title → edit mode. Escape → cancel. Enter → submits the Turbo Frame form, server responds with a stream, row updates. No state management library. No event bus. 43 lines of JavaScript.
Gotcha: data-action shorthand vs explicit event names
data-action="click->controller#method" defaults to the element’s default event when you omit the event — click for buttons, submit for forms, input for inputs. This is convenient but can surprise you. When in doubt, be explicit: data-action="dblclick->inline-edit#edit".
Realtime With Turbo Streams Over ActionCable
Everything so far is request/response. Hotwire also supports server-initiated updates via ActionCable (WebSockets). When another user adds a task, everyone’s page updates without a refresh.
# app/models/task.rb
class Task < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "tasks" }
after_update_commit -> { broadcast_replace_to "tasks" }
after_destroy_commit -> { broadcast_remove_to "tasks" }
end
<%# app/views/tasks/index.html.erb — add this before the tasks div %>
<%= turbo_stream_from "tasks" %>
That’s it. The model broadcasts Turbo Stream messages over the "tasks" channel whenever a task changes. Every subscribed client applies the update. Three lines of model code, one view tag.
Gotcha: N+1 broadcasts
If you do Task.all.each(&:save) in a migration or console, you’ll fire a broadcast for every record. In production this can overwhelm ActionCable. Use broadcast_later methods for async delivery, and be careful in bulk operations.
# Safer for bulk ops:
Task.update_all(completed: false)
# ^ No callbacks, no broadcasts — that's intentional here
Production Checklist
A few things that bite people who ship Hotwire apps without thinking them through:
Idempotent forms. Double-submitting a form is easier with Turbo because the UI updates optimistically. Add data-turbo-submits-with="Saving..." to disable the submit button during submission:
<%= f.submit "Save", data: { turbo_submits_with: "Saving..." } %>
Caching with Turbo Frames. Turbo caches pages for back/forward navigation. If a frame contains user-specific content, set the correct Cache-Control headers or disable the preview cache:
<meta name="turbo-cache-control" content="no-preview">
Flash messages. Redirects work fine, but stream responses don’t trigger a new page load, so your flash messages won’t appear automatically. Common pattern: render a flash stream template partial alongside your main update:
<%# app/views/tasks/create.turbo_stream.erb %>
<%= turbo_stream.prepend "tasks", partial: "task", locals: { task: @task } %>
<%= turbo_stream.replace "flash", partial: "shared/flash" %>
Morphing (Rails 7.1+). The newest addition is turbo_refreshes_with method: :morph. Instead of replacing the full <body>, Turbo uses DOM diffing (via idiomorph) to patch only changed nodes. Combine with <%= turbo_stream_from "tasks", refresh: :morph %> for nearly-invisible updates without writing explicit stream templates. It’s not always the right tool — it can cause transition animations to glitch — but for data-heavy dashboards it’s excellent.
When Not to Use Hotwire
Hotwire is not the answer to everything. Be honest with yourself:
- Heavy client-side state (a multi-step wizard that never touches the server between steps, a canvas editor, a rich text editor) — reach for Stimulus sparingly and accept you might need a small React island.
- Offline-first apps — Hotwire assumes network availability. Service workers and IndexedDB are outside its scope.
- High-frequency real-time data (live trading feeds, multiplayer game state) — broadcasting one Turbo Stream per change at 50 updates/second isn’t the right architecture. You need a proper WebSocket protocol with binary frames, not HTML snippets.
For everything else — CRUD apps, dashboards, admin panels, marketing sites with dynamic bits, SaaS products — Hotwire covers 90% of the interactivity you actually need, at a fraction of the complexity cost.
The Bigger Point
The React ecosystem answered a real problem: server-rendered apps of the early 2010s were slow and janky. But the solution overshot. We traded slow pages for massive JS bundles, hydration waterfalls, two separate codebases, and the constant feeling that your frontend is a foreign country.
Hotwire bets that the browser is fast enough now, HTTP is cheap enough, and most interactivity doesn’t actually require client-side state management. That bet pays off in almost every Rails application I’ve seen. Your controller stays the controller. Your templates stay templates. You write less code, ship faster, and debug with byebug instead of React DevTools.
The official repository: github.com/hotwired/hotwire-rails. The Turbo handbook at turbo.hotwired.dev is genuinely well-written — read it cover to cover once.
Start with Turbo Drive (you already have it). Add a Turbo Frame the first time you catch yourself thinking "I wish this didn’t reload the whole page." Add a Stimulus controller the first time you reach for document.querySelector. You’ll be surprised how far that gets you before you ever need to npm install react.