"We ditched React—and our frontend got faster, simpler, and 10x more fun to build."
For years, Rails developers faced a brutal choice:
- Stick with jQuery-era spaghetti code, or
- Go all-in on React and lose the "Rails way" magic.
Then came Hotwire Turbo, Stimulus, and Alpine.js—a trio that gives us modern interactivity without the JavaScript fatigue.
After rebuilding our entire admin dashboard with this stack, here’s what we learned—and when you should (and shouldn’t) use it.
1. Meet the Players
Turbo: The Page Speed Wizard
- Turbo Drive: Makes navigation instant (no full page reloads)
- Turbo Frames: Update parts of the page without JavaScript
- Turbo Streams: Real-time DOM updates over WebSockets
<!-- Example: Lazy-load a dashboard section -->
<turbo-frame id="analytics" src="/dashboard/analytics">
<p>Loading...</p>
</turbo-frame>
Best for: Apps that feel like SPAs without writing API endpoints.
Stimulus: The Minimalist Controller Layer
- Lightweight (~10KB)
- Attaches behavior to HTML (no virtual DOM)
- Works with Turbo seamlessly
// controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
search() {
fetch(`/search?q=${this.inputTarget.value}`)
.then(response => response.text())
.then(html => this.resultsTarget.innerHTML = html)
}
}
Best for: Adding sprinkles of JavaScript without going full React.
Alpine.js: The HTML-Embeddable React Alternative
- Declarative UI in HTML (like Vue templates)
- Reactive state management
- No build step needed
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open" x-transition>
Hello from Alpine!
</div>
</div>
Best for: Components that need client-side state (dropdowns, modals, tabs).
2. Why This Stack Rocks
✅ The Rails Way, Modernized
- Server-rendered HTML (bye-bye, JSON APIs)
- Progressively enhanced (works without JavaScript)
- No Webpacker/ESBuild headaches (import-maps FTW)
✅ Faster Than React for CRUD Apps
Metric | React + API | Turbo + Stimulus |
---|---|---|
Time to First Render | 1.2s | 0.4s |
Bundle Size | 150KB | 25KB |
Websocket Support | Custom | Built-in |
✅ Less Code, Fewer Bugs
// React (30+ lines)
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// Alpine (5 lines)
<div x-data="{ count: 0 }">
<button @click="count--">-</button>
<span x-text="count"></span>
<button @click="count++">+</button>
</div>
3. When It Falls Short
❌ Not for Full-Blown SPAs
- Complex state management? Use React/Vue.
- Offline support? Stick with PWA frameworks.
❌ Steeper Learning Curve Than jQuery
- Turbo’s morphing rules can confuse beginners.
- Stimulus requires thinking in controllers, not jQuery-style DOM hacking.
❌ Less Component Reusability
- React’s
props
system is more robust than Stimulus’sdata-
attributes.
4. Real-World Use Cases
Case 1: Live Search (Turbo + Stimulus)
<!-- View -->
<div data-controller="search">
<input type="text" data-search-target="input" @input="search">
<div data-search-target="results"></div>
</div>
<!-- Rails Controller -->
def search
@products = Product.where("name LIKE ?", "%#{params[:q]}%")
render partial: "results"
end
Why it wins:
- Zero JavaScript state management
- Works with back/forward cache
Case 2: Dynamic Forms (Alpine)
<form x-data="{ paymentType: 'credit_card' }">
<select x-model="paymentType">
<option value="credit_card">Credit Card</option>
<option value="paypal">PayPal</option>
</select>
<div x-show="paymentType === 'credit_card'">
Credit Card Fields...
</div>
<div x-show="paymentType === 'paypal'">
PayPal Email...
</div>
</form>
Why it wins:
- No Stimulus controllers needed
- State lives in the template
5. The Hybrid Approach
Turbo for Navigation
# config/application.rb
config.view_component.default_preview_layout = "turbo_rails"
Stimulus for Behavior
// controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.showModal() // Native HTML dialog!
}
}
Alpine for Client-State Components
<!-- In a Turbo Frame -->
<turbo-frame id="cart">
<div x-data="{ items: <%= @cart_items.to_json %> }">
<template x-for="item in items">
<div x-text="item.name"></div>
</template>
</div>
</turbo-frame>
6. Getting Started
1. Add to Rails
# Gemfile
gem "hotwire-rails"
gem "stimulus-rails"
bundle install
bin/rails hotwire:install
2. Import Alpine
<!-- app/views/layouts/application.html.erb -->
<%= javascript_importmap_tags %>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
3. Profit
The Verdict
Use this stack if:
- You’re building a traditional Rails app
- You want SPA-like speed without APIs
- Your team hates JavaScript tooling
Avoid if:
- You need complex client-side state
- You’re building a mobile app
Our experience?
- Admin dashboards: Perfect fit
- E-commerce carts: Great
- Real-time games: Still need React
"But We Already Have React!"
Start small:
- Add Turbo Drive (instant win)
- Replace one React component with Alpine
- Measure performance
Tried this stack? Share your wins/fails below!
Top comments (0)