What Replacing React with Hotwire Really Costs
It’s common to see a Rails app using React to handle front-end interactions, with a Redux store that mostly mirrors the database and react-router re-declaring routes Rails already knows about. React does its job well enough, but every user-facing feature now costs twice, once in Ruby and once in JavaScript.
So eventually the question will appear: what would it take to delete all of this and use a Rails-way solution like Hotwire ? The honest answer is “it depends.” In this article, we will go through what the code difference actually looks like, what you can expect from bundle size, which pain points to plan for, and how to scope the work before committing to it.
The code difference
Creating a plain list of todos in classic React + Redux takes an action creator, a reducer, and a connected component. That is three JS files, plus an API controller and serializer on the Rails side. Redux Toolkit collapses a lot of this boilerplate, but the apps that raise this question are rarely on it:
// actions/todos.js
export const fetchTodos = () => async (dispatch) => {
dispatch({ type: "FETCH_TODOS_REQUEST" });
try {
const res = await fetch("/api/todos");
dispatch({ type: "FETCH_TODOS_SUCCESS", payload: await res.json() });
} catch (e) {
dispatch({ type: "FETCH_TODOS_FAILURE", error: e.message });
}
};
// reducers/todos.js
const initialState = { items: [], loading: false, error: null };
export default function todos(state = initialState, action) {
switch (action.type) {
case "FETCH_TODOS_REQUEST": return { ...state, loading: true };
case "FETCH_TODOS_SUCCESS": return { ...state, loading: false, items: action.payload };
case "FETCH_TODOS_FAILURE": return { ...state, loading: false, error: action.error };
default: return state;
}
}
// components/TodoList.jsx
class TodoList extends Component {
componentDidMount() { this.props.fetchTodos(); }
render() {
const { items, loading } = this.props;
if (loading) return <p>Loading…</p>;
return <ul>{items.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}
}
export default connect((s) => ({ items: s.todos.items, loading: s.todos.loading }), { fetchTodos })(TodoList);
The same feature in Hotwire would require only a controller and a view, no store, no serializer, no loading state, because the server sends HTML that’s already populated:
# app/controllers/todos_controller.rb
class TodosController < ApplicationController
def index
@todos = current_user.todos
end
end
<%# app/views/todos/index.html.erb %>
<ul>
<% @todos.each do |todo| %>
<li><%= todo.title %></li>
<% end %>
</ul>
That gap is why the migration is worth doing, and it’s also where the cost comes from. Porting a screen means deleting a whole layer and moving its responsibilities back to the server, which is slower work than translating code line for line. That cost lands on developers, and it’s paid during the migration rather than after.
It’s hard to port a screen mechanically. Each one has to be rethought: where its state lived, what the server can render directly, and what genuinely needs to stay in JavaScript. The client-side tests won’t carry over either, so every flow has to be re-covered.
Interactivity
Hotwire covers interactivity in two ways. For server-driven updates, Turbo Streams replace just the piece that changed, no reducer, no re-rendering the whole list. A simple toggle action that used to be a Redux action and a connected component becomes a single controller action and a Turbo Stream view that re-renders the todo partial:
<%# app/views/todos/toggle.turbo_stream.erb %>
<%= turbo_stream.replace @todo %>
For client-side behavior, a small Stimulus controller replaces what used to be a stateful component. The mental shift is the whole point: a Redux store that mirrors the database makes the client the source of truth and syncs it to the server; Hotwire keeps the server as the source of truth.
The case for the second model isn’t speed. React can be fast, and on most screens users won’t feel the difference either way. The real cost is keeping two copies of the truth in sync: every mutation updates the server, then reconciles the local store, invalidates its cache, and manages its own loading, error, and rollback states. A data layer like React Query or RTK Query automates much of that, but the apps that prompt this question are usually on a hand-rolled Redux store where all of it is yours to maintain.
Hotwire deletes most of that work: with one source of truth, there’s no client store to reconcile. Cache invalidation is still yours, and Turbo’s page cache can still show a stale preview on a restoration visit, but you maintain one copy of the truth instead of two. Where a screen genuinely needs rich client state, React earns that overhead; most screens don’t, and that’s the trade the migration is really making.
Bundle size
This is the number stakeholders actually feel. react + react-dom alone are about 45 KB gzipped before Redux, the router, and your own code, and a mature SPA bundle usually lands in the low hundreds of KB gzipped. Turbo and Stimulus together are about 35 KB gzipped, and with importmaps you can often drop the JS build step entirely. That leaves a hundred KB or more of JavaScript that never has to be downloaded, parsed, and executed on every page load.
What drives the cost, and what bites
Two apps with the same page count can differ in effort by an order of magnitude. The estimate is driven by how much genuine client-side state and app-like behavior you have (optimistic UI, wizards, real-time dashboards, drag-and-drop), far more than by raw page count.
A handful of pain points show up in this work every time, and they are worth planning for before the first screen moves.
Most of the Redux store turns out to be server state in disguise, and it disappears along with the API that fed it. The hard cases are optimistic UI and unsaved multi-step forms, and for each of those you have to decide deliberately whether it becomes a server round trip, a bit of Stimulus state, or a screen you leave in JavaScript for now. Routing is a similar story. Anything stored in the front-end route, a modal as a URL or a wizard step, needs a home on the server, so it’s worth verifying deep links, the back button, and scroll restoration as you go.
The complex widgets deserve to be identified first. They are usually the wrong thing to rebuild in Hotwire and the right thing to keep as islands, since reimplementing a mature JS date picker or drag-and-drop grid in Stimulus is where effort tends to blow up. The JSON API is easier to reason about: if it only feeds React, you delete it along with the front end, but if a mobile app or a partner also consumes it, you will be maintaining two paradigms for the length of the transition.
Testing and authentication are the two slices teams tend to underestimate. The React app’s component tests won’t translate, and end-to-end coverage of the flows you touch is what makes this safe, so expect to write tests ideally before each migration rather than after. And if the SPA authenticates with tokens, moving to Rails sessions and cookies touches login, session expiry, and CSRF, which is enough surface area to plan as its own slice instead of folding it into a screen.
How to scope it
A practical approach is to turn the unknown into a spreadsheet: inventory every screen, Redux slice, and front-end-only API endpoint, then bucket each screen. A trivially server-rendered, B needs a Stimulus sprinkle, C needs a real JS island, D stays as React for now.
Starting with the A/B buckets gets the cheapest wins first, and builds the team’s Hotwire fluency before anything hard gets touched. It’s the same logic we apply to upgrading Rails in increments , where small shippable slices beat a long-lived branch. To keep the work fundable, track numbers people can watch: lines of JavaScript deleted, dependencies removed, bundle size, and screens migrated out of the total.
Conclusion
One caveat before you start: this work pays off least when the app is genuinely application-like, such as heavy real-time collaboration, serious offline support, or rich client state a round trip would ruin. There the SPA earns its keep, and the better investment is modernizing it.
For everything else, the honest estimate comes out of the inventory: how many screens land in the A and B buckets, how many complex widgets you have to keep as islands, and how much of the Redux store turns out to be server state in disguise. Start with the cheap buckets, track the numbers people can watch, and let the hard screens wait until the team has the Hotwire fluency to handle them. One thing worth keeping in mind: the D bucket is allowed to stay a D bucket. An app that serves most of its screens with Hotwire and keeps three React islands is a perfectly reasonable place to stop, and stopping there is usually cheaper than chasing the last few screens.
Weighing a move off React and Redux and want a realistic read on the cost and the right incremental path? Talk to us today!