For a long time, my instinct whenever a Rails page needed to do something dynamic was to reach for JavaScript. A dropdown, a live-updating counter, a form that didn’t reload the whole page — my brain would immediately start thinking about how much of a frontend framework I’d need to bolt on. I’d done enough React to be dangerous, and dangerous is the right word, because what I usually ended up with was a Rails app with a confused little JavaScript app living inside it, two sources of truth, and a sync problem I’d created for myself.
Hotwire broke me of that habit. It’s how Rails 7 and 8 handle interactivity, and once it clicked, I stopped reaching for a separate frontend framework for most of what I build.
First lesson of the day: hotwire is not a single tool.
It’s two libraries that solve different problems — Turbo and Stimulus — and most of my early confusion came from not knowing which one I was supposed to be using.
Two libraries, two jobs
Turbo handles content. Stimulus handles behavior. That’s the mental model that eventually stuck with me, and it’s how I’ve learned to apply it.
Turbo is the big structural stuff — navigating between pages, swapping out a chunk of HTML, streaming updates down from the server. Stimulus is the small interactive stuff — a button that does something when you click it, a character counter, a toggle. The rule I eventually internalized: if I’m swapping HTML, it’s probably Turbo. If I’m reacting to an event with some logic, it’s probably Stimulus. Most real features use both.
Turbo Frames, where it clicked
The first piece of Turbo you get for free — Turbo Drive intercepts link clicks and form submissions and swaps the page without a full reload, so your app feels like a single-page application without you writing any client-side routing. It’s on by default. But the piece that made me understand what people were excited about was Turbo Frames.
A frame lets you update one part of a page independently. You wrap a section in a <turbo-frame> tag with an id, and any link or form inside it updates only that frame.
<turbo-frame id="cart_summary">
<p>Items in cart: <%= @cart.items.count %></p>
<%= link_to "Refresh", cart_path %>
</turbo-frame>
Click that link, and Turbo fetches cart_path, finds the matching frame in the response, and swaps in just that frame’s contents. Everything else sits still. No flicker, no full reload, no JavaScript.
What I find clever about this is that the server response is just a normal full page render. Your controller doesn’t have to know the request came from inside a frame — it renders the view like it always does, and Turbo on the client picks out the matching frame and discards the rest. The same endpoint works whether you hit it directly or through a frame. You’re not writing special partial endpoints.
This one feature replaced a startling amount of the JavaScript I used to write. Inline editing, expandable rows, tabs, lazy-loaded sections — all Turbo Frames, no custom JS.
Turbo Streams, when the server needs to push
Streams go a step further. Instead of swapping one frame, the server sends back a list of targeted operations — append this, replace that, remove the other thing — and Turbo applies each one.
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments" do %>
<%= render @comment %>
<% end %>
The action is append, the target is the DOM id comments, the content is your rendered partial. There’s a small vocabulary of these — append, prepend, replace, update, remove — and once you’ve got it, you’re basically describing DOM surgery in ERB.
The part that changed how I think about this: you can broadcast these over a websocket straight from your model.
class Comment < ApplicationRecord
belongs_to :post
broadcasts_to :post
end
That one line means when a comment is created, everyone currently viewing that post gets it streamed into their page in real time. You didn’t write a websocket handler. You didn’t write client-side state management. You described the change in Ruby and ERB, and it shows up everywhere.
That’s the whole reason this feels lighter than the framework approach. In a React-style setup, the server has the truth and the client has a copy of the truth, and a big chunk of your code exists just to keep them in agreement. Turbo Streams keep the truth on the server and ship HTML. The sync problem just isn’t there — and you don’t feel how much that problem was costing you until it’s gone.
Stimulus, for the JavaScript you still need
Turbo gets you a long way, but not all the way. Sometimes you genuinely need client-side behavior — a dropdown, a character counter, a copy-to-clipboard button. None of that is “swap HTML from the server,” so it’s Stimulus’s job.
Stimulus is deliberately small. It connects JavaScript to your HTML through controllers (a bundle of behavior), targets (the elements it cares about), and actions (the events that trigger it).
<div data-controller="clipboard">
<input data-clipboard-target="source" type="text" value="Copy me">
<button data-action="click->clipboard#copy">Copy</button>
</div>
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
}
}
Something that took me a minute to understand…stimulus doesn’t render anything. Your HTML is still rendered by Rails, the way it always has been. Stimulus just attaches behavior to elements that already exist — those data-* attributes are the wiring.
And that’s exactly why it fits with Turbo so well. Because Stimulus connects to elements by scanning the DOM, when Turbo swaps in new HTML, Stimulus automatically connects controllers to anything new. You don’t re-initialize anything after a navigation or a stream update. You never write the glue code you’d have to write in other setups.
When I reach for which
After enough features, the decision tree is roughly:
- Navigating, forms, whole-page changes → Turbo Drive, automatic
- Updating one section → Turbo Frame
- Pushing changes from the server, real-time → Turbo Streams
- Click handlers, toggles, small widgets → Stimulus
- Genuinely heavy client-side state — a drawing tool, a spreadsheet, a canvas app → a real frontend framework, and that’s fine
That last one matters. Hotwire isn’t trying to win every fight. If your interactivity genuinely lives on the client and the server’s mostly out of the loop, React is the right tool and Hotwire would be in your way. What Hotwire claims is narrower: most web apps are mostly content with some behavior sprinkled in, and for those, you can stay in Rails, write a fraction of the JavaScript, and end up with something simpler to reason about.
I hope this was helpful - Hotwire can be extremely handy in the right use case. If you notice any mistakes or would like to comment, feel free to reach me at: sam@samperozek.com