As a developer who has extensively used both Rails and React, I want to share my experiences and explain why, for front-end development, I now favour React over Rails. (Note that my personal experience is with React and Rails but you could just as easily replace them with other tools in the same spaces such as Svelte and Laravel, or SolidJS and Django.)
From around 2006 to 2016, I was a dedicated and happy full-stack Rails developer. However, the evolution of web applications and user expectations led me to transition the front-end of my applications to React. These days my projects are a mix of React front-ends with Rails APIs, or full-stack React projects using Remix.¹
It may seem weird to be writing this post now, rather than, say, 8 years ago. Recently Rails has made big strides with its Hotwire approach to building applications and there’s been a lot of chatter on Twitter from people asserting that with these changes React is overcomplicated and unnecessary. (I will note that a lot of these opinions are coming from people who have been saying that JavaScript frameworks are overcomplicated and unnecessary for over a decade.)
Recently someone asked me what React components offer that can’t be achieved with Rails partials and Hotwire. The key difference for me is that React components have the advantage of being rendered on both the server and the client. This dual rendering offers a seamless user experience, particularly when it comes to dynamic UI changes. For instance, consider a UI with a list of items and a counter displaying the number of items.
Here’s how I would render this list in a Rails partial:
<ul>
<% @items.each do |item| %>
<li><%= item.name %></li>
<% end %>
</ul>
Total: <%= @items.size %>
And in a React component:
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
)}
</ul>
Total: {items.length}
So far we’re not seeing much advantage to React here. But let’s talk about interactivity and dynamism. In Rails, to update this list and the related counter upon deleting an item, we have three options.
- Perform a full page refresh.
- Use a partial page refresh with Turbo.
- Build a Stimulus controller to perform an AJAX request and manually update the DOM.
For a lot of apps a full page refresh is just fine, and a partial refresh with Turbo is more than fine. But if you want native-like interactivity you just can’t be waiting for the server reponse to update the UI after each action the user takes. Even on a very fast connection the small amount of latency will be perceptible and frustrating to a user that’s trying to do their work quickly. And that’s ignoring what it feels like to use on a bad connection.
So this leaves you with option 3: writing a Stimulus controller to manually orchestrate the AJAX request and update the DOM. In my experience, this is where your likelihood of stepping on rakes skyrockets. In this relatively simple example there are two places in the UI that are derived state from your items: the list and the counter. In every scenario where an item gets added you’d better be sure to increment the count, and in every scenario where an item gets removed you’d better not forget to decrement it. This seems straightforward enough, but multiply the number of actions that can change the data by the number of places in the UI that represent the data and you can easily find yourself forgetting to do some orchestration that leaves your UI out of sync with reality. I built apps this way for years and these types of bugs are extremely commonplace. Can you spot the bug I’ve introduced in the code below?
<div data-controller="list">
<ul>
<% @items.each do |item| %>
<li>
<form data-action="submit->list#delete" data-id="<%= item.id %>">
<%= item.name %>
<button type="submit">Delete</button>
</form>
</li>
<% end %>
</ul>
Total: <span data-target="count"><%= @items.size %></span>
</div>
export default class ListController extends Controller {
static targets = ["count"];
connect() {
console.log("List controller connected");
}
async delete(event) {
event.preventDefault();
const form = event.target;
const itemId = form.dataset.id;
try {
// Hide the element first in case deletion fails
form.parentElement.style.display = "none";
this.countTarget.textContent = parseInt(this.countTarget.textContent) - 1;
await api.deleteListItem(itemId);
// Then remove it on success
form.parentElement.remove();
} catch (e) {
// If the server fails, make it visible again
form.parentElement.style.display = "block";
}
}
}
Now let’s try it with React. With a single state variable, like listOfItems, managing this list and its associated counter becomes straightforward. The counter automatically updates based on listOfItems.length
. Updating the state variable leads to the automatic and immediate update of the UI without the need for explicit synchronization or separate controller actions.
function List() {
const [items, setItems] = useState(/* an array of items */)
const handleDelete = async (itemId: number) => {
try {
setItems(items.map((item) => (
item.id === itemId ? { ...item, deleted: true } : item
)))
await api.deleteItem(itemId);
} catch (e) {
setItems(items.map((item) => (
item.id === itemId ? { ...item, deleted: false } : item
)))
}
}
const visibleItems = items.filter((i) => !i.deleted);
return (
<div>
<ul>
{visibleItems.map((item) => (
<li key={item.id}>
<form onSubmit={e => {
e.preventDefault()
handleDelete(item.id)
}}>
{item.name}
<button type="submit">Delete</button>
</form>
</li>
)}
</ul>
Total: {visibleItems.length}
</div>
)
}
Notice that in our Rails implementation we are primarily concerned with manual DOM manipulation, while in our React implementation we are simply updating our data and letting the DOM sort itself out. And this is just with plain jane React. Tools like Remix can take this orchestration between client and server to the next level and handle a lot of tricky edge cases for you out of the box, such as cancelling and discarding the results of successive form submissions.
While it’s absolutely possible to achieve the exact same functionality in Rails, the manual orchestration required to maintain UI state grows complex with scale. On top of that, you lose the DX gains of colocating your behaviour with your markup and having basic type-checking. In the React component if I accidentally try to call handleDestroy
instead of handleDelete
I’ll get an error in my editor and my code won’t compile. In the Rails example if I make a typo in any of the Stimulus data tags I’ll have no feedback about why my code isn’t working (ask me how I know 😅)
If you’re certain that you won’t need this level of fidelity in your applications then Rails with Hotwire remains a very capable way to build and ship apps. But in the work I do I don’t have that kind of certainty, and that’s why I’ll continue to reach for React when it comes to building front-ends.
1. Deciding whether to use Rails or JavaScript on the back-end is a different matter entirely. For the sake of this post I’m really only interested in view rendering.