Building Reactive Interfaces with Hotwire and Turbo: A Practical Guide
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:
- Prepends it to the transactions list
- Updates the account balance
- 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:
- User clicks “Edit”
- Turbo Frame loads
edit_transaction_path
- Form appears inline replacing the transaction row
- User saves → row updates with new data
- 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:
- Highly Interactive Apps: Complex drag-and-drop, canvas manipulation, or real-time collaboration might need more JavaScript
- Mobile Apps: Native mobile apps need native code, not HTML over the wire (though Turbo Native helps)
- Offline-First: Apps requiring offline functionality need more client-side logic
- 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.
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