The Real Question: Do You Need a SPA?
When starting a new Rails project, the biggest architectural decision isn't database or deployment strategy—it's whether to render HTML server-side or build a single-page application. Hotwire vs React: Ruby on Rails best practices explained depends entirely on your application's needs, team expertise, and timeline. We've shipped production systems with both approaches, and the answer isn't "React is better" or "Hotwire always wins." It's more nuanced than that.
Hotwire: Server-Rendered, Less JavaScript
Hotwire is Rails' modern answer to building interactive web applications without writing much JavaScript at all. It consists of three complementary technologies: Turbo (for fast navigation and form handling), Stimulus (for sprinkles of interactivity), and Strada (for native iOS/Android bridges, though less mature). The core philosophy is radical: keep your business logic in Rails, render views on the server, and use lightweight JavaScript to intercept browser behavior.
Here's a concrete example. Imagine building a project management dashboard where users can create tasks, mark them complete, and filter by status—all without page refreshes. With Hotwire, your Rails controller returns HTML fragments:
class TasksController < ApplicationController
def create
@task = current_user.tasks.build(task_params)
if @task.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to tasks_path }
end
else
render :new, status: :unprocessable_entity
end
end
def update
@task = Task.find(params[:id])
@task.update(task_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @task }
end
end
endYour view template uses Turbo streams to replace or append DOM elements:
# app/views/tasks/create.turbo_stream.erb
<%= turbo_stream.prepend "tasks-list", @task %>
<%= turbo_stream.update "task-form", partial: "form" %>The browser sends a request, the server processes it, and Hotwire pushes back an HTML fragment that updates only the changed part of the page. No JSON marshaling, no client-side state management, no bundle size bloat. Your JavaScript stays minimal—Stimulus controllers handle UI state when you need it:
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "backdrop"]
open() {
this.modalTarget.classList.remove("hidden")
this.backdropTarget.classList.remove("hidden")
}
close() {
this.modalTarget.classList.add("hidden")
this.backdropTarget.classList.add("hidden")
}
}In production, we've built invoice dashboards, admin panels, and real-time notification feeds this way. The development velocity is exceptional. A team member new to Rails can ship a feature in hours rather than days because they're writing one language and one logical flow instead of coordinating client and server code.
React: Rich Client-Side Interactions
React shines when your application demands complex, stateful interactions that don't fit the request-response model. Think real-time collaborative editing, multi-step forms with live validation, infinite-scroll feeds, or drag-and-drop kanban boards where every click triggers cascading UI updates without server round-trips.
With React, your Rails backend becomes primarily an API. You structure routes to return JSON, and React handles all rendering:
# config/routes.rb
namespace :api do
namespace :v1 do
resources :tasks, only: [:index, :create, :update, :destroy]
end
end
class Api::V1::TasksController < ApplicationController
def index
@tasks = current_user.tasks.order(:created_at)
render json: @tasks
end
def create
@task = current_user.tasks.build(task_params)
if @task.save
render json: @task, status: :created
else
render json: @task.errors, status: :unprocessable_entity
end
end
endOn the frontend, you manage component state, handle optimistic updates, and orchestrate the user experience entirely in JavaScript:
// app/javascript/components/TaskList.jsx
import React, { useState, useEffect } from 'react'
export default function TaskList() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/v1/tasks')
.then(res => res.json())
.then(data => {
setTasks(data)
setLoading(false)
})
}, [])
const toggleTask = async (id, completed) => {
setTasks(tasks.map(t =>
t.id === id ? { ...t, completed: !completed } : t
))
await fetch(`/api/v1/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !completed })
})
}
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id, task.completed)}
/>
{task.title}
</li>
))}
</ul>
)
}React's strength is predictability at scale. With Redux, Zustand, or Jotai, you can manage application state explicitly. Animations are smooth. The user never waits for a server response to see their action reflected—you update the UI immediately and sync with the backend in the background. We've built trading dashboards, CRM interfaces, and ride-hailing platforms this way. The user experience is crisp and responsive, but the complexity is real.
Hotwire Is Faster to Build CRUD Apps
Most business applications are CRUD at their core: create, read, update, delete. They need a form to add records, a table to list them, and quick edits without full-page reloads. Hotwire dominates this space.
Consider building an e-commerce admin panel where you manage product inventory. With Hotwire, you might build it in a single afternoon:
# app/views/products/index.html.erb
<div id="products">
<%= form_with model: Product.new, local: true do |f| %>
<%= f.text_field :name, placeholder: "Product name" %>
<%= f.number_field :price %>
<%= f.submit "Add Product" %>
<% end %>
<table>
<thead>
<tr><th>Name</th><th>Price</th><th>Actions</th></tr>
</thead>
<tbody id="products-list">
<%= render @products %>
</tbody>
</table>
</div>
# app/views/products/_product.html.erb
<tr id="<%= dom_id(product) %>">
<td><%= product.name %></td>
<td><%= product.price %></td>
<td>
<%= link_to "Edit", edit_product_path(product), data: { turbo_frame: "edit-form" } %>
<%= link_to "Delete", product_path(product), method: :delete, data: { turbo_confirm: "Sure?" } %>
</td>
</tr>Add a controller that returns Turbo streams for create and delete, and you have an interactive admin panel with zero React boilerplate. No component trees, no Redux setup, no JavaScript bundler configuration. The feedback is immediate. Your team ships features and validates with users faster.
React introduces friction for this use case. You'd spend the first two hours configuring a create-react-app or Next.js setup, managing two separate processes (Rails server and build server), syncing state between client and server, and writing end-to-end tests that exercise both layers. By the time you're done, Hotwire would have shipped three features.
Mix Both When Needed
The best Rails applications aren't purely Hotwire or purely React. They're hybrid. You might build 80% of your UI with Hotwire—dashboards, forms, tables, notifications—and drop in a React component for the 20% that genuinely needs rich interactivity.
Here's how we structure this in practice. A travel booking platform uses Hotwire for the search results page and booking form, but embeds a React component for an interactive map where users drag a region to filter destinations:
# app/views/destinations/search.html.erb
<div class="search-container">
<div id="filter-sidebar">
<%= form_with method: :get, local: true do |f| %>
<!-- Hotwire form for text, date, price filters -->
<%= f.text_field :query %>
<%= f.submit "Search" %>
<% end %>
</div>
<div id="map-container">
<!-- React component mounted here -->
<%= react_component("MapFilter", { initialBounds: @bounds }) %>
</div>
<div id="results">
<%= render @destinations %>
</div>
</div>The React map component broadcasts changes to a hidden form field, which Hotwire picks up and re-renders the results via Turbo. Both systems coexist peacefully. Your developers can work in Rails when it makes sense and drop into React only when the UI demands it.
In our experience, this hybrid approach scales well. We've run production systems with perhaps 15-20% React by bundle size. It keeps development complexity manageable while letting you deliver polished UX where it matters.
Common Mistakes
Choosing React Too Early
The biggest trap we see is teams jumping to React because it "feels modern" or because someone learned it in a bootcamp. Hotwire can handle 90% of traditional web applications. Evaluate your actual requirements first. If your app is mostly forms and data display, Hotwire will ship faster and cost less to maintain.
Over-Engineering State Management
When you do use React, resist the urge to model every piece of UI state in Redux or Zustand before you need it. Start simple. Use component state. Add a store only when you're passing props through five levels of components or managing complex interdependencies. Over-architecting kills productivity.
Forgetting Progressive Enhancement
Whether you choose Hotwire or React, build for resilience. With Hotwire, ensure forms work if JavaScript fails (they usually do). With React, serve a loading state and meaningful error handling. A user on a flaky 3G connection should see something useful, not a blank screen.
Duplicating Business Logic
If you go hybrid, be disciplined about where validation lives. Validate on the server always. Client-side validation is UX, not security. We've seen teams build elaborate validation logic in React, only to ship vulnerabilities because the Rails API accepted invalid data. Single source of truth in your models.
Key Takeaways
- Hotwire is optimized for CRUD applications and server-rendered HTML. Build most of your admin panels, dashboards, and data entry interfaces this way.
- React excels when you need rich client-side state and smooth, stateful interactions. Use it for real-time collaborative tools, complex filtering UIs, and smooth animations.
- Start with Hotwire. It's faster to ship, easier to reason about, and requires less operational overhead. Migrate features to React only when Hotwire genuinely can't deliver the UX you need.
- Hybrid applications work well. Render most of your app with Hotwire and embed React components where necessary. Keep the boundary clear.
- Validate all business logic on the server. Client-side validation is a convenience, not a security boundary.
- Measure your actual performance and user experience. Don't choose a technology based on hype or resume-driven development. Ship what serves your users fastest.






Comments
Loading comments…