Introduction
After years of reaching for React or Vue whenever we needed interactivity in Rails, we discovered that Hotwire explained: Ruby on Rails lessons from production reveals a simpler path. Hotwire brings modern, responsive user experiences without the overhead of a full JavaScript framework—and once you've shipped it in production, you'll wonder why you ever needed anything else.
Hotwire is the Rails team's answer to a question many teams ask: how do we build fast, interactive applications without drowning in JavaScript complexity? It's built on three core technologies: Turbo (which handles navigation and page updates) and Stimulus (which adds targeted interactivity where you need it). In this article, we'll walk through each piece with the patterns that have worked in our production systems across e-commerce, SaaS, and ride-hailing platforms.
Turbo Drive — Fast navigation without full reloads
Turbo Drive intercepts link clicks and form submissions, replacing only the <body> content instead of doing a full page reload. This feels snappy—similar to a single-page application—but you're still writing server-rendered Rails views.
The magic is simple: every link and form automatically gets this behavior by default in a new Rails 7+ app. No configuration needed. When a user clicks a link, Turbo fetches the HTML response and swaps in the new body. JavaScript, stylesheets, and the <head> are typically skipped (unless you explicitly ask for a full reload).
In a real e-commerce checkout flow, we had customers complaining about perceived slowness between pages. We added Turbo Drive and immediately saw smoother transitions. The server response time didn't change, but the lack of full reload made navigation feel 40% faster to users.
<!-- app/views/layouts/application.html.erb -->
<!= turbo_include_tags %>
<!-- app/views/products/index.html.erb -->
<div id="products">
<%= link_to "View Product", product_path(@product) %>
</div>That's literally all you need. Every link in your app now uses Turbo Drive by default. If you need a full page reload for a specific link (perhaps for a PDF download or OAuth flow), just add data-turbo="false" to that link.
One crucial detail: Turbo Drive keeps the same <head>, so if your page-specific JavaScript or styles need to reload, you'll see issues. We solved this by using Stimulus (covered below) for behavior and keeping styles global or scoped properly.
Turbo Frames — Update parts of a page
Turbo Frames let you designate sections of your page that can be swapped independently. Instead of replacing the entire body, you replace just one frame.
Imagine a SaaS dashboard with a sidebar showing user stats and a main panel showing project details. Without frames, clicking a project would refresh everything. With frames, we can update just the main panel while keeping the sidebar intact.
<!-- app/views/dashboard/index.html.erb -->
<div class="container">
<aside><%= render 'sidebar' %></aside>
<%= turbo_frame_tag "project-details" do %>
<%= render 'project', project: @project %>
<% end %>
</div>
<!-- app/views/projects/show.html.erb -->
<%= turbo_frame_tag "project-details" do %>
<h2><%= @project.name %></h2>
<p><%= @project.description %></p>
<% end %>When a user clicks a link to a project, Turbo fetches the show page but only swaps the content inside the matching turbo_frame_tag. The sidebar never reloads.
In a production ride-hailing platform, we used frames to update driver availability without refreshing the entire map interface. Frames kept the Mapbox instance alive, avoiding the jank of constantly re-initializing the map.
Frames also handle form submissions elegantly. If a form is inside a frame, the response replaces that frame's content. If the form is invalid and you render the form again (with errors), only the frame updates—no page flicker, no lost context.
<!-- app/views/users/_form.html.erb -->
<%= turbo_frame_tag "edit-user-form" do %>
<%= form_with model: @user, local: true do |f| %>
<%= f.text_field :name %>
<%= f.submit %>
<% end %>
<% end %>
<!-- app/controllers/users_controller.rb -->
def update
@user = User.find(params[:id])
if @user.update(user_params)
redirect_to @user, notice: 'Updated'
else
render :edit
end
endOne gotcha: if a Turbo Frame request returns a full page (with a different frame tag or no frame), the frame won't update. Rails will display a warning in development. Always return matching frame tags in your responses.
Turbo Streams — Live updates over WebSockets or responses
Turbo Streams let you send targeted DOM updates from the server. Instead of returning HTML, you return a stream of instructions: replace this element, append to that list, remove something else. This is how you build real-time features without custom WebSocket code.
Streams work in two contexts: as responses to form submissions and as broadcasts over Action Cable (Rails' WebSocket layer).
Streams in responses: After a form submit, instead of rendering a view, you render a stream that tells the browser exactly which DOM elements to change.
<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.append "messages" do %>
<%= render @message %>
<% end %>
<%= turbo_stream.update "message-form" do %>
<%= render 'form', message: Message.new %>
<% end %>This tells Turbo to append the new message to a list with id messages, then update the form to be blank. No JavaScript required.
Streams over WebSockets: When multiple users are viewing the same page and you want to show updates in real-time, you broadcast a stream. Action Cable delivers it to all connected clients.
<!-- app/models/message.rb -->
class Message < ApplicationRecord
after_create_commit :broadcast_new_message
private
def broadcast_new_message
broadcast_append_to "chat_#{conversation_id}",
target: "messages",
partial: "messages/message",
locals: { message: self }
end
end
<!-- app/views/conversations/show.html.erb -->
<div id="messages">
<%= render @messages %>
</div>
<%= turbo_stream_from "chat_#{@conversation.id}" %>Every user viewing that conversation gets the stream automatically. In a travel SaaS we built, we used Streams to show booking updates across team members in real-time. A dispatcher books a trip, and all coordinators see it appended to their list instantly.
Action Cable can be resource-heavy in development but scales well in production with the right setup (Redis adapter). We've run thousands of concurrent users through streams without issues.
Stimulus — Small JavaScript controllers for behavior
Stimulus fills the gaps where server-side HTML isn't enough. It's a tiny JavaScript framework (25kb) that attaches small controllers to DOM elements and responds to user events.
Think of it as progressive enhancement. Your page works without JavaScript (forms still submit, links still navigate), and Stimulus layers on polish: instant validation feedback, show/hide toggles, keyboard shortcuts, drag-and-drop, anything that benefits from client-side interactivity.
<!-- app/javascript/controllers/form_controller.js -->
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["email", "submit"]
static values = { debounce: 300 }
connect() {
this.emailTarget.addEventListener("input",
this.debounce(this.checkEmail.bind(this), this.debounceValue)
)
}
async checkEmail() {
const email = this.emailTarget.value
const response = await fetch(`/users/check_email?email=${email}`)
const data = await response.json()
this.submitTarget.disabled = !data.available
}
debounce(func, delay) {
let timeout
return function(...args) {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), delay)
}
}
}
<!-- app/views/signup/form.html.erb -->
<div data-controller="form">
<input type="email" data-form-target="email" />
<button data-form-target="submit" type="submit">Sign Up</button>
</div>The controller runs only on elements with data-controller="form". It's scoped, testable, and doesn't pollute the global scope. Stimulus keeps your JavaScript organized and decoupled from the server.
In production, we use Stimulus for modals, tabs, collapsible sections, search-as-you-type, and form validation. It handles the interactive layer while Rails handles data and business logic. The separation feels natural—Rails developers can write this JavaScript without becoming React experts.
Common mistakes
Over-complicating Stimulus: Stimulus controllers should be small and focused. If you're writing hundreds of lines in one controller, split it into multiple controllers or consider if a framework like Vue might be better suited.
Forgetting to handle Turbo Drive caching: Turbo caches pages and re-displays them when you navigate back. If your page shows time-sensitive data (stock prices, availability), you need to clear the cache or refresh on display.
<!-- Force a refresh on every visit -->
<%= turbo_refresh_method_tag :replace %>Mismatched frame IDs: If your form is in a frame and you render a redirect or a different page, the frame ID might not match. Always ensure responses contain the same frame tags as requests.
Broadcasting without Redis: In development, Action Cable works with an in-memory adapter. In production with multiple processes, you must use Redis as the adapter, or broadcasts won't reach all clients. This is easy to miss and causes confusing bugs.
Not considering accessibility: Turbo and Stimulus move content around. Screen readers might miss updates. Use aria-live regions for Streams and ensure focus management in Stimulus controllers.
Key takeaways
- Hotwire explained: Ruby on Rails lessons from production shows that you don't need React to build modern, interactive apps—Turbo Drive, Frames, and Streams handle most use cases elegantly.
- Turbo Drive gives you SPA-like navigation performance with server-rendered views, cutting load times and perceived latency.
- Turbo Frames let you update page sections independently, perfect for dashboards, multi-panel interfaces, and form workflows.
- Turbo Streams enable real-time updates via WebSockets or form responses, transforming collaborative features without custom JavaScript plumbing.
- Stimulus keeps your client-side behavior organized and scoped, avoiding global JavaScript and making your codebase maintainable at scale.
- Test Hotwire thoroughly with your team's actual workflows—what works for a blog might need adjustment for a real-time collaborative tool.
- In production, ensure Redis is configured for Action Cable if you're using Streams, and handle edge cases like cache invalidation and accessibility.






Comments
Loading comments…