rails hotwire frontend

Hotwire and Turbo in Rails: Where Server-Rendered UI Fits

9 min read

Use Hotwire and Turbo where Rails owns page state: frames for scoped updates, streams for live regions, Stimulus for the rest, and when React still wins.

Hotwire and Turbo reactive interface patterns for Ruby on Rails applications

The dashboard looked like a React app because nobody had questioned that choice in years. It had filters, paginated tables, inline edits, live totals, and a small order form. The Rails backend already rendered most of the same HTML for emails and exports, but the browser path went through JSON serializers, a frontend store, and a separate build pipeline.

Moving that kind of screen to Hotwire is not about shaving a few kilobytes of JS. The useful change is removing the second model of the page: filters become normal Rails requests inside a Turbo Frame, server-side status changes become Turbo Streams, and Stimulus stays only for the few interactions the browser genuinely owns. Line counts and bundle sizes will vary by app; the decision is ownership of state, not a before/after scoreboard.

This is the pattern I now reach for in Rails apps that are mostly forms, tables, dashboards, admin screens, and account workflows. I would not use it to replace client-side state that is genuinely the product.

The table below is a decision guide for this class of Rails screen, not a ranking of frontend tools.

Concern Hotwire + Turbo React SPA Vue + Inertia
Frontend bundle size Small Hotwire/Stimulus surface Depends on app and framework Depends on app and framework
Build tooling required Often none beyond Rails defaults Usually Vite or equivalent Usually Vite
State management Server-side (Rails) Redux/Zustand/Context Pinia/Vuex
Real-time updates Built-in (Turbo Streams) Requires extra libraries Requires extra libraries
SEO/SSR Works by default Needs Next.js or SSR setup Needs Nuxt or SSR setup
Learning curve for Rails devs Low High Medium
API layer needed No Yes (JSON API) Optional (Inertia)
Team structure Full-stack Frontend + Backend Full-stack possible

The boundary in this migration was narrow: one dashboard whose state already lived in Rails. We did not move a drawing surface, offline workflow, or multiplayer editor. The parts that stayed in JavaScript were the parts where the browser genuinely owned the moment-to-moment state: dropdowns, a modal, and a debounced search input.

The server-state boundary

Hotwire sends HTML from the server instead of JSON. That removes the client-side model for screens where Rails already owns the data: no API serializer just to render a table, no store just to remember filters, and no virtual DOM just to replace a row.

The whole idea fits in one sentence: the server renders HTML and the browser swaps it into place, so there is no second, client-side model of the page to keep in sync. That is what removes Webpack, Redux, and the serialization layer, not some clever runtime. You write controllers, views, and partials the way you already do, and because the fallback for every interaction is a normal Rails request, pages still work with JavaScript turned off.

For apps that are mostly CRUD over a database, this covers nearly everything the frontend framework was doing.

Use this boundary before choosing a tool:

  • If the server owns the data and the interaction can tolerate a request/response cycle, start with Turbo Frames or Streams.
  • If the browser owns temporary state such as drag position, canvas state, offline edits, or multiplayer presence, keep that state in JavaScript.
  • If both are true, split the surface: Rails renders the durable state, Stimulus owns the small browser-only behavior.

The three primitives that matter

Turbo Drive is on by default: links and forms fetch the next page and swap <body> without reloading CSS/JS. You rarely write Drive-specific code; you mostly notice when something needs data-turbo="false".

Turbo Frames scope updates. Clicks and form posts inside a frame replace only that region:

<%= turbo_frame_tag "transactions_list" do %>
  <%= render @transactions %>
  <%= paginate @transactions %>
<% end %>

Pagination inside the frame leaves the header and sidebar alone. That is usually the first win when killing a React table that only existed to avoid full-page reloads.

Turbo Streams return (or broadcast) multiple DOM ops from the server:

# app/controllers/transactions_controller.rb
def create
  @transaction = current_user.transactions.create!(transaction_params)

  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to transactions_path }
  end
end
<%# app/views/transactions/create.turbo_stream.erb %>
<%= turbo_stream.prepend "transactions_list", @transaction %>
<%= turbo_stream.update "balance", partial: "shared/balance" %>

One screen: frames + streams + one Stimulus controller

Start with an admin-style page: list, filters or pagination in a frame, create via stream, one browser-only widget. You do not need a multi-region "finance dashboard" cosplay to prove the point.

Frame + stream on create

<%= turbo_stream_from "user_#{current_user.id}_orders" %>

<%= turbo_frame_tag "orders_list" do %>
  <%= render @orders %>
<% end %>

<%= turbo_frame_tag "order_form" do %>
  <%= render "orders/form", order: Order.new %>
<% end %>
# After a background job finishes work the browser did not start:
class OrderStatusJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    order.update!(status: :executed, executed_at: Time.current)

    Turbo::StreamsChannel.broadcast_replace_to(
      "user_#{order.user_id}_orders",
      target: ActionView::RecordIdentifier.dom_id(order),
      partial: "orders/order",
      locals: { order: order }
    )
  end
end

Broadcast one targeted replace until a second panel truly depends on the same write. Hotwire does not poll frames for free; if the server does not know about a change, a stream will not appear.

Inline edit without a client store

Shared dom_id on the row and the edit view is the whole trick:

<%# _order.html.erb %>
<%= turbo_frame_tag dom_id(order) do %>
  <div class="order-row">
    <%= order.reference %>
    <%= link_to "Edit", edit_order_path(order) %>
  </div>
<% end %>
<%# edit.html.erb %>
<%= turbo_frame_tag dom_id(@order) do %>
  <%= form_with model: @order do |f| %>
    <%= f.text_field :reference %>
    <%= f.submit "Save" %>
    <%= link_to "Cancel", order_path(@order) %>
  <% end %>
<% end %>

Stimulus only where the browser owns state

// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => this.formTarget.requestSubmit(), 300)
  }
}
<%= form_with url: search_path, method: :get,
              data: { controller: "search", turbo_frame: "search_results", search_target: "form" } do |f| %>
  <%= f.search_field :q, data: { action: "input->search#search" } %>
<% end %>

<%= turbo_frame_tag "search_results" do %>
  <%= render @results if @results %>
<% end %>

Dropdowns and modals get the same treatment: tiny controllers, no app-wide store.

Where this breaks (and what to measure)

  • Hover prefetch is on in Turbo 8 (~100ms hover). Opt out expensive or destructive links with data: { turbo_prefetch: "false" }. Prefetch requests send X-Sec-Purpose: prefetch if the server must refuse background work.
  • Lazy frames (loading: :lazy + src:) defer secondary panels; do not put the primary decision UI behind them.
  • Offline, canvas, multiplayer, complex drag-and-drop still need client-owned state. Do not force Turbo into that role.
  • Healthy React apps are not free to rewrite. Migrate screens where Rails already owns the data and React is a translation layer.

System tests stay ordinary Capybara: click, fill_in, assert on the frame or list. No special Turbo test harness required for the basic path.

When NOT to use Hotwire

Avoid it when the browser owns durable product state (drawing tools, offline-first, multiplayer presence). Avoid a rewrite whose only goal is "remove React." Keep React/Vue on the surfaces that fail the server-state boundary, and use Hotwire where filters, tables, forms, and status rows were never a SPA problem.

What I would ship first

Pick one admin table with filters, pagination, and one inline edit. Rails 8 already has Hotwire; on Rails 7 run bundle add hotwire-rails and rails hotwire:install. Replace one full reload with a frame, one create/update with a stream, leave the rest of the app alone. Measure request count and how much client JS that screen still needs. Move the next screen only if the first one removed a real dual-model cost.

What usually disappears on that path (measure yours; not a benchmark): dashboard-only JSON API, a client store that mirrored Rails records, a frontend build step for that page, and frontend/backend handoff on every filter change. What remains: Stimulus for browser-owned behavior, frames for scoped regions, streams for server-pushed rows.

The trade-off I would accept

What you accept is a different ownership model, not a smaller React. Rails owns durable state, the server renders HTML, the browser swaps fragments. That fits SaaS admin panels, account workflows, internal tools, and e-commerce back offices where correctness of data matters more than canvas interaction.

I would not use it for offline-first apps, complex builders, multiplayer editing, or UI where most value lives in client-side state. I would not rewrite a healthy React app just to remove React. The best Hotwire migrations start where React is mostly translating Rails data back into HTML Rails could have rendered directly.

When a Rails screen feels like a small SPA only because it has filters, pagination, and inline edits, it usually never needed to be one. Decide per page: which frame owns each interaction, which updates need streams, which bits still deserve Stimulus.

Further Reading