rails hotwire frontend

Hotwire and Turbo in Rails: Production Patterns Guide

- 16 min read

Replace React with Hotwire and Turbo in Rails. Production patterns for Turbo Frames, Streams, and Stimulus with performance comparisons and migration results.

Hotwire and Turbo reactive interface patterns for Ruby on Rails applications

Migrating a production dashboard from React to Hotwire cut our frontend code by 94% - from 3,200 lines of JavaScript to 180 lines of Stimulus controllers. In my previous post about Rails 8, I mentioned that Hotwire deserves its own deep dive. Here is what I’ve learned building real-time interfaces with it.

Feature Hotwire + Turbo React SPA Vue + Inertia
Frontend bundle size ~28kb 150-400kb 80-200kb
Build tooling required None Webpack/Vite + config Vite + config
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 Hotwire Philosophy

Hotwire sends HTML from the server instead of JSON, eliminating the need for build tools, state management, or a virtual DOM. Your existing Rails knowledge applies directly, and it works without JavaScript enabled.

Core principles:

  • HTML over the wire: Server renders HTML, not JSON for client-side rendering
  • Progressive enhancement: Works without JavaScript
  • Minimal frontend complexity: No Webpack, no Redux, no virtual DOM
  • Real Rails code: Controllers, views, and partials - nothing new to learn

For SaaS platforms, admin tools, and e-commerce backends, Hotwire hits the sweet spot of developer productivity and user experience.

The Three Pillars of Hotwire

1. Turbo Drive: Fast Page Navigation

Turbo Drive makes every link click and form submission instant by intercepting them and swapping page content without a full reload. No configuration needed - your existing Rails app feels like a SPA immediately.

<!-- Regular Rails link -->
<%= link_to "Dashboard", dashboard_path %>

<!-- Turbo Drive automatically makes this a fast navigation -->
<!-- No page reload, instant transition -->

2. Turbo Frames: Scoped Updates

Turbo Frames update specific parts of the page without full refreshes. Wrap any section in a turbo_frame_tag, and clicks within that frame only replace that section - the rest of the page stays untouched.

<!-- app/views/transactions/index.html.erb -->
<div class="page-header">
  <h1>Transactions</h1>
</div>

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

<aside class="sidebar">
  <%= render "filters" %>
</aside>

When a user clicks pagination inside the frame, only the transactions_list frame updates. The header and sidebar stay untouched.

3. Turbo Streams: Real-time Updates

Turbo Streams push real-time DOM updates from the server via WebSocket, SSE, or HTTP responses. They support multiple operations - append, prepend, replace, update, remove - across different page elements in a single response.

# app/controllers/transactions_controller.rb
def create
  @transaction = Transaction.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" %>
<%= turbo_stream.replace "notification", partial: "shared/success" %>

When a transaction is created, this:

  1. Prepends it to the transactions list
  2. Updates the account balance
  3. Shows a success notification

All without writing a single line of JavaScript.

Production Example: Real-Time Trading Dashboard

Here’s how to build a real-time trading dashboard. Requirements:

  • Live price updates every 5 seconds
  • Real-time order execution
  • Portfolio balance updates
  • Transaction history

Setup: The Layout

<!-- app/views/dashboards/show.html.erb -->
<div class="dashboard-grid">
  <!-- Live Prices -->
  <%= turbo_frame_tag "live_prices",
                       src: live_prices_path,
                       refresh: "every 5s" do %>
    <%= render "loading_prices" %>
  <% end %>

  <!-- Portfolio Balance -->
  <%= turbo_frame_tag "portfolio_balance",
                       src: portfolio_balance_path do %>
    <%= render @portfolio %>
  <% end %>

  <!-- Order Form -->
  <%= turbo_frame_tag "order_form" do %>
    <%= render "orders/form" %>
  <% end %>

  <!-- Recent Transactions -->
  <%= turbo_stream_from "user_#{current_user.id}_transactions" %>
  <%= turbo_frame_tag "recent_transactions" do %>
    <%= render @recent_transactions %>
  <% end %>
</div>

Live Price Updates

# app/controllers/live_prices_controller.rb
class LivePricesController < ApplicationController
  def show
    @prices = PriceService.current_prices(symbols: params[:symbols])

    # Turbo Frame automatically handles the refresh
    render turbo_frame: "live_prices"
  end
end
<!-- app/views/live_prices/show.html.erb -->
<%= turbo_frame_tag "live_prices" do %>
  <div class="prices-grid">
    <% @prices.each do |symbol, price| %>
      <div class="price-card" data-symbol="<%= symbol %>">
        <span class="symbol"><%= symbol %></span>
        <span class="price <%= price_change_class(price) %>">
          $<%= number_with_precision(price.current, precision: 2) %>
        </span>
        <span class="change">
          <%= price.change_percent %>%
        </span>
      </div>
    <% end %>
  </div>
  <p class="text-xs text-gray-500">
    Updated <%= time_ago_in_words(Time.current) %> ago
  </p>
<% end %>

Result: Prices update every 5 seconds automatically. No WebSocket complexity, no JavaScript. Just HTML.

Real-time Order Execution

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = current_user.orders.build(order_params)

    if @order.save
      # Process order asynchronously
      OrderExecutionJob.perform_later(@order.id)

      respond_to do |format|
        format.turbo_stream {
          render turbo_stream: [
            turbo_stream.replace("order_form", partial: "orders/form", locals: { order: Order.new }),
            turbo_stream.prepend("pending_orders", partial: "orders/order", locals: { order: @order }),
            turbo_stream.update("flash", partial: "shared/success", locals: { message: "Order placed successfully" })
          ]
        }
      end
    else
      respond_to do |format|
        format.turbo_stream {
          render turbo_stream: turbo_stream.replace("order_form", partial: "orders/form", locals: { order: @order })
        }
      end
    end
  end
end

Broadcasting Updates from Background Jobs

# app/jobs/order_execution_job.rb
class OrderExecutionJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)

    # Execute order with external API
    result = TradingAPI.execute(order)

    if result.success?
      order.update!(status: :executed, executed_at: Time.current)

      # Broadcast updates to user's dashboard
      broadcast_order_execution(order)
    else
      order.update!(status: :failed)
      broadcast_order_failure(order)
    end
  end

  private

  def broadcast_order_execution(order)
    # Update multiple parts of the UI
    Turbo::StreamsChannel.broadcast_replace_to(
      "user_#{order.user_id}_transactions",
      target: "order_#{order.id}",
      partial: "orders/executed_order",
      locals: { order: order }
    )

    # Update portfolio balance
    Turbo::StreamsChannel.broadcast_update_to(
      "user_#{order.user_id}_portfolio",
      target: "portfolio_balance",
      partial: "dashboards/portfolio_balance",
      locals: { portfolio: order.user.portfolio }
    )

    # Show notification
    Turbo::StreamsChannel.broadcast_append_to(
      "user_#{order.user_id}_notifications",
      target: "notifications",
      partial: "shared/notification",
      locals: { message: "Order executed: #{order.symbol} #{order.quantity} @ #{order.executed_price}" }
    )
  end
end

Result: When an order executes (async in background), the UI updates in real-time:

  • Order moves from “pending” to “executed”
  • Portfolio balance updates
  • Notification appears

All of this happens automatically for any user viewing their dashboard.

Advanced Pattern: Inline Editing

Turbo Frames enable inline editing with zero JavaScript - clicking “Edit” swaps a display row with a form, and saving swaps it back. No page navigation, no modal, no custom JavaScript.

<!-- app/views/transactions/_transaction.html.erb -->
<%= turbo_frame_tag dom_id(transaction) do %>
  <div class="transaction-row">
    <div class="transaction-details">
      <span class="date"><%= transaction.date.strftime("%b %d, %Y") %></span>
      <span class="description"><%= transaction.description %></span>
      <span class="amount"><%= number_to_currency(transaction.amount) %></span>
    </div>
    <div class="actions">
      <%= link_to "Edit", edit_transaction_path(transaction), class: "btn-sm" %>
    </div>
  </div>
<% end %>
<!-- app/views/transactions/edit.html.erb -->
<%= turbo_frame_tag dom_id(@transaction) do %>
  <%= form_with model: @transaction do |f| %>
    <div class="inline-edit-form">
      <%= f.text_field :description, class: "form-input" %>
      <%= f.text_field :amount, class: "form-input" %>

      <div class="actions">
        <%= f.submit "Save", class: "btn-primary" %>
        <%= link_to "Cancel", transaction_path(@transaction), class: "btn-secondary" %>
      </div>
    </div>
  <% end %>
<% end %>

What happens:

  1. User clicks “Edit”
  2. Turbo Frame loads edit_transaction_path
  3. Form appears inline replacing the transaction row
  4. User saves → row updates with new data
  5. All other transactions stay untouched

Zero JavaScript. Just Rails conventions.

Adding Interactivity with Stimulus

Stimulus handles the 5-10% of interactions that genuinely need client-side JavaScript - dropdowns, modals, debounced inputs, and client-side validation. It keeps JavaScript organized in small controllers tied to HTML elements via data attributes.

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

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

  toggle() {
    this.menuTarget.classList.toggle("hidden")
  }

  hide(event) {
    if (!this.element.contains(event.target)) {
      this.menuTarget.classList.add("hidden")
    }
  }
}
<!-- app/views/shared/_user_menu.html.erb -->
<div data-controller="dropdown" data-action="click@window->dropdown#hide">
  <button data-action="click->dropdown#toggle" class="user-avatar">
    <%= current_user.avatar %>
  </button>

  <div data-dropdown-target="menu" class="dropdown-menu hidden">
    <%= link_to "Profile", profile_path %>
    <%= link_to "Settings", settings_path %>
    <%= link_to "Logout", logout_path, data: { turbo_method: :delete } %>
  </div>
</div>

Stimulus philosophy: Sprinkle JavaScript where needed, not everywhere.

Performance Considerations

1. Frame Eager Loading

Preload Turbo Frame content on hover to make navigation feel instant. When the user clicks, Rails has already loaded the content in the background:

<%= link_to "View Details",
            transaction_path(@transaction),
            data: { turbo_frame: "modal", turbo_prefetch: true } %>

2. Lazy Loading Frames

Defer non-critical content by lazy loading frames - content loads only when scrolled into view, reducing initial page load time:

<%= turbo_frame_tag "analytics",
                     src: analytics_path,
                     loading: :lazy do %>
  <!-- Shows while loading -->
  <%= render "loading_skeleton" %>
<% end %>

3. Debouncing Searches

Combine Stimulus with Turbo Frames for real-time search that updates as you type. A 300ms debounce prevents excessive requests while keeping the interface responsive:

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

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

  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" },
                      placeholder: "Search transactions..." %>
<% end %>

<%= turbo_frame_tag "search_results" do %>
  <!-- Results appear here -->
<% end %>

Result: Search updates as you type (with 300ms debounce), results appear inline.

Testing Hotwire Features

Turbo Frames and Streams work seamlessly with Rails system tests - no complicated async mocking or special configuration required. Tests read like user interactions:

# test/system/transactions_test.rb
class TransactionsTest < ApplicationSystemTestCase
  test "creating a transaction updates the list" do
    visit transactions_path

    click_on "New Transaction"

    within "#new_transaction" do
      fill_in "Description", with: "Coffee"
      fill_in "Amount", with: "5.50"
      click_on "Create"
    end

    # Turbo Stream automatically updates the list
    assert_selector "#transactions_list", text: "Coffee"
    assert_selector "#transactions_list", text: "$5.50"

    # Balance updated
    assert_selector "#balance", text: "$494.50" # assuming starting balance
  end

  test "editing a transaction inline" do
    transaction = transactions(:one)

    visit transactions_path

    within dom_id(transaction) do
      click_on "Edit"

      fill_in "Description", with: "Updated description"
      click_on "Save"

      # Frame updates inline
      assert_text "Updated description"
    end
  end
end

When NOT to Use Hotwire

Avoid Hotwire for highly interactive applications requiring complex drag-and-drop, canvas manipulation, or real-time collaboration. Consider alternatives for:

  1. Highly Interactive Apps: Complex drag-and-drop, canvas manipulation, or real-time collaboration might need more JavaScript
  2. Mobile Apps: Native mobile apps need native code, not HTML over the wire (though Turbo Native helps)
  3. Offline-First: Apps requiring offline functionality need more client-side logic
  4. Existing React Codebase: Migrating working React apps is usually not worth it

Production Results

Migrating a production dashboard from React to Hotwire cut frontend code by 94%, eliminated the API layer entirely, and reduced the development team from 3 specialists to 2 full-stack developers. Here are the detailed numbers:

Before (React + Rails API):

  • Frontend bundle: 340kb (gzipped)
  • Build time: 45 seconds
  • Lines of JavaScript: ~3,200
  • API endpoints: 24
  • Development team: 2 frontend + 1 backend

After (Hotwire):

  • Frontend bundle: 28kb (Hotwire + Stimulus)
  • Build time: 0 seconds (no build)
  • Lines of JavaScript: ~180 (Stimulus controllers)
  • API endpoints: 0 (just Rails views)
  • Development team: 2 full-stack developers

User Experience:

  • Faster initial load (smaller bundle)
  • Snappier interactions (HTML is faster than JSON → render)
  • Same real-time feel as before

Developer Experience:

  • 60% reduction in total code
  • Bugs reduced by half (less JavaScript = less bugs)
  • New features ship 40% faster
  • No frontend/backend coordination needed

Getting Started Checklist

Want to build with Hotwire? Here’s your path:

1. Setup (Rails 8+)

# Already included in Rails 8
# For Rails 7, add:
bundle add hotwire-rails
rails hotwire:install

2. Start Simple

  • Replace one full page reload with Turbo Frame
  • Add one Turbo Stream action
  • Build confidence incrementally

3. Common Patterns to Learn

  • Inline editing with Turbo Frames
  • Live search with debouncing
  • Real-time updates with broadcasts
  • Modal dialogs with Turbo Frames

4. Resources

The Bottom Line

Hotwire trades frontend flexibility for speed of delivery - and for most Rails apps, that trade pays off.

For most web applications - SaaS products, admin panels, internal tools, e-commerce - Hotwire delivers:

  • Faster development: Less code, less complexity
  • Better performance: Smaller bundles, faster interactions
  • Easier maintenance: One language, one framework
  • Great UX: Real-time updates without sacrificing simplicity

Is it as flexible as React? No. Do you need React’s flexibility for 90% of applications? Also no.

What’s Next?

For the database side of performance, my post on database optimization techniques for Rails covers indexing strategies, query optimization, and N+1 elimination patterns that pair well with Hotwire’s server-rendered approach.


Need help building reactive interfaces with Hotwire? I help teams adopt Hotwire patterns and migrate from heavy frontend frameworks. Reach out at nikita.sinenko@gmail.com.

Further Reading