rails hotwire frontend

Building Reactive Interfaces with Hotwire and Turbo: A Practical Guide

Hotwire, Turbo, Stimulus, Ruby on Rails, Real-time, SPA, Frontend

Learn how to build fast, reactive user interfaces with Hotwire and Turbo without writing a single line of React or Vue. Real-world patterns from building FinTech dashboards.

In my previous post about Rails 8, I mentioned that Hotwire deserves its own deep dive. After building multiple real-time dashboards for FinTech applications here in Dubai, I’ve learned that Hotwire isn’t just a React alternative—it’s a fundamentally different (and often better) approach to building reactive interfaces.

The Hotwire Philosophy

Before diving into code, let’s understand what makes Hotwire different:

  • HTML over the wire: Server sends HTML, not JSON
  • Progressive enhancement: Works without JavaScript
  • Minimal frontend complexity: No build tools, state management, or virtual DOM
  • Real Rails code: Your existing Rails knowledge applies directly

This isn’t about being “anti-JavaScript”—it’s about choosing the right tool for the job. For many applications, especially B2B SaaS and FinTech platforms, Hotwire hits the sweet spot of developer productivity and user experience.

The Three Pillars of Hotwire

1. Turbo Drive: Fast Page Navigation

Turbo Drive intercepts link clicks and form submissions, making navigation instant.

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

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

That’s it. No configuration needed. Your multi-page Rails app now feels like an SPA.

2. Turbo Frames: Scoped Updates

Turbo Frames update specific parts of the page without full refreshes.

<!-- 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 enable real-time updates via WebSocket, SSE, or HTTP responses.

# 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.

Real-World Example: FinTech Dashboard

Let me show you how I built a real-time trading dashboard for a client. 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

A common need: edit records inline without page navigation.

<!-- 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

For interactions that truly need JavaScript (dropdowns, modals, client-side validation), Stimulus provides a lightweight solution.

// 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

For instant navigation, eager load frames on hover:

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

When user hovers, Rails loads content in background. Click feels instant.

2. Lazy Loading Frames

For non-critical content, lazy load:

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

Frame loads only when scrolled into view.

3. Debouncing Searches

For real-time search, debounce with Stimulus:

// 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 works seamlessly with Rails system tests:

# 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

Tests read like user interactions. No complicated mocking or async handling.

When NOT to Use Hotwire

Hotwire isn’t always the answer. 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

Real-World Results

After migrating a FinTech dashboard from React to Hotwire:

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 represents a return to Rails’ roots: convention over configuration, developer happiness, and pragmatic choices.

For most web applications—especially B2B SaaS, FinTech platforms, and internal tools—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?

In my next post, I’ll dive into database optimization techniques for Rails applications—covering indexing strategies, query optimization, and N+1 elimination patterns that have saved millions of queries in production FinTech applications.


Building a real-time dashboard or considering Hotwire for your Rails app? I’m available for consulting and pair programming sessions. Based in Dubai, working with clients globally. Reach out at nikita.sinenko@gmail.com.

Code Repository

Want to see a complete Hotwire dashboard example? Check out my GitHub for sample projects and patterns.

N

Need help with your Rails project?

I'm Nikita Sinenko, a Senior Ruby on Rails Engineer with 15+ years of experience. Based in Dubai, working with clients worldwide on contract and consulting projects.

Let's Talk