Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 022 / 100

Hotwire vs React: Ruby on Rails best practices explained

Do you need a SPA?

Expona Admin8 min read

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:

ruby
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
end

Your view template uses Turbo streams to replace or append DOM elements:

ruby
# 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:

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

ruby
# 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
end

On the frontend, you manage component state, handle optimistic updates, and orchestrate the user experience entirely in JavaScript:

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

ruby
# 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:

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

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles