Turbo and Stimulus: Rails' Modern Frontend

You don't need React for most Rails apps. Turbo and Stimulus handle complex interfaces with far less code. One language. One codebase. No separate frontend. Here's how to build modern, reactive Rails apps without adding a JavaScript framework.

Turbo and Stimulus: Rails' Modern Frontend

You're building a Rails app. You want it to feel fast and interactive.

Your first thought might be React. Or Vue. Or some other JavaScript framework.

But what if I told you that you don't need any of them?

Rails has its own frontend solution. It's called Hotwire. It's built into Rails 7. And it handles most interactive features with far less code.

Let me show you how it works.


The Short Answer

Hotwire is Rails' modern frontend approach. It consists of Turbo and Stimulus.

Turbo handles page updates without JavaScript: faster navigation, inline editing, real-time updates.

Stimulus adds JavaScript behavior to specific elements: copy buttons, form validation, dynamic UI.

Together, they replace most SPA frameworks. One language. One codebase. No separate frontend.


Part 1: What is Hotwire?

Hotwire stands for HTML Over The Wire.

Instead of sending JSON from the backend and rendering it on the frontend, Hotwire sends HTML directly.

CODE
Traditional SPA: Browser → JSON API → React → Render
Hotwire: Browser → HTML → Turbo → Update

This is faster. Simpler. And uses far less code.

The Two Parts

Component Purpose
Turbo Handles HTML updates: navigation, forms, real-time
Stimulus Adds JavaScript behavior to specific elements

Why It Matters

Problem Solution
Slow page loads Turbo Drive speeds navigation
Complex form interactions Turbo Frames handle inline edits
Real-time updates Turbo Streams push changes
JavaScript boilerplate Stimulus provides structure

Part 2: Why Not React?

Before we dive into Hotwire, let's be honest about when it's better than React.

The Cost of React

Factor React + Rails API Rails + Hotwire
Codebases 2 (Rails + React) 1 (Rails)
Languages Ruby + JavaScript Ruby (and a little JS)
API maintenance Yes, constant No
Onboarding time Weeks Days
Deployment complexity High Low
Lines of code 2x-3x more Less

When to Use React

React is still the right choice when:

  • Real-time collaboration (Figma, Google Docs)
  • Offline-first applications
  • Desktop-like complex UI (drag-and-drop, complex state)
  • Mobile apps with React Native
  • Your team already knows React

When to Use Hotwire

Hotwire is better when:

  • Most CRUD apps (B2B SaaS, internal tools)
  • Simple to moderate interactivity
  • You want to ship fast
  • You're a solo developer or small team
  • You want one codebase, one language

For most Rails apps, Hotwire is the better choice.


Part 3: Turbo Drive

Turbo Drive makes page navigation faster. It intercepts link clicks and form submissions, fetches the new page in the background, and replaces the body content.

How It Works

JAVASCRIPT
// No code needed. It just works.
// Turbo intercepts all links and form submissions
ERB
<%# Regular Rails link - Turbo handles it automatically %>
<%= link_to "About", about_path %>

Why It's Faster

Traditional Turbo Drive
Full page reload Only body content updates
Re-downloads all assets Reuses CSS and JavaScript
Flash of white page Smooth transition

Opting Out

Sometimes you don't want Turbo behavior.

ERB
<%# Disable Turbo for specific links %>
<%= link_to "Logout", logout_path, data: { turbo: false } %>

<%# Or disable for forms %>
<%= form_with model: @user, data: { turbo: false } do |f| %>

Turbo in the Browser

JAVASCRIPT
// Listen to Turbo events
document.addEventListener("turbo:load", function() {
  console.log("Page loaded via Turbo");
});

document.addEventListener("turbo:before-fetch-request", function() {
  // Show loading spinner
});

document.addEventListener("turbo:visit", function() {
  // Track page visits
});

Part 4: Turbo Frames

Turbo Frames update specific parts of a page without reloading the whole thing.

Basic Frame

ERB
<%= turbo_frame_tag "product_list" do %>
  <%= render @products %>
<% end %>

Lazy Loading

ERB
<%# Content loads only when scrolled into view %>
<%= turbo_frame_tag "comments", src: comments_path(@product), loading: :lazy do %>
  <p>Loading comments...</p>
<% end %>

Inline Editing

ERB
<%# app/views/products/index.html.erb %>
<%= turbo_frame_tag "product_#{product.id}" do %>
  <div class="product">
    <h3><%= product.name %></h3>
    <p><%= product.price %></p>
    <%= link_to "Edit", edit_product_path(product) %>
  </div>
<% end %>

<%# app/views/products/edit.html.erb %>
<%= turbo_frame_tag "product_#{@product.id}" do %>
  <%= form_with model: @product do |f| %>
    <%= f.text_field :name %>
    <%= f.submit "Save" %>
  <% end %>
<% end %>

Frame Navigation

ERB
<%# Links inside frames stay inside the frame %>
<%= turbo_frame_tag "dashboard" do %>
  <h2>Dashboard</h2>
  <%= link_to "Recent Orders", orders_path %>

  <%= turbo_frame_tag "orders_list", src: orders_path %>
<% end %>

Frame Target

ERB
<%# Link outside frame targeting a specific frame %>
<%= link_to "Show Product", product_path(@product), data: { turbo_frame: "product_details" } %>

<%= turbo_frame_tag "product_details" do %>
  <!-- Content will be replaced here -->
<% end %>

Part 5: Turbo Streams

Turbo Streams deliver real-time updates over WebSockets.

Basic Stream

ERB
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.append "posts" do %>
  <%= render @post %>
<% end %>

<%= turbo_stream.replace "post_form" do %>
  <%= render "form", post: Post.new %>
<% end %>

Stream Actions

Action Purpose
append Add content to the end of an element
prepend Add content to the beginning of an element
replace Replace existing content
update Update content of an element
remove Remove an element

Real-World Examples

Comments Section

RUBY
# app/controllers/comments_controller.rb
def create
  @comment = @post.comments.create(comment_params)

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.append("comments", partial: "comment")
    end
  end
end
ERB
<%# app/views/posts/show.html.erb %>
<div id="comments">
  <%= render @post.comments %>
</div>

<%= turbo_stream_from @post, :comments %>

Notifications

RUBY
# app/controllers/notifications_controller.rb
def mark_as_read
  @notification = current_user.notifications.find(params[:id])
  @notification.mark_as_read!

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.remove(@notification),
        turbo_stream.replace("notification_count", partial: "count")
      ]
    end
  end
end

Broadcasting from Models

RUBY
# app/models/post.rb
class Post < ApplicationRecord
  after_create_commit -> { broadcast_append_to "posts", partial: "posts/post", locals: { post: self } }
  after_update_commit -> { broadcast_replace_to "posts" }
  after_destroy_commit -> { broadcast_remove_to "posts" }
end

Streaming with Channel

RUBY
# app/channels/posts_channel.rb
class PostsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "posts"
  end
end
ERB
<%# app/views/posts/index.html.erb %>
<%= turbo_stream_from "posts" %>

<div id="posts">
  <%= render @posts %>
</div>

Part 6: Stimulus

Stimulus adds JavaScript behavior to specific elements. It's not a framework. It's a modest JavaScript framework.

Basic Controller

JAVASCRIPT
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source", "feedback"]
  static values = { timeout: Number }

  copy() {
    const text = this.sourceTarget.textContent
    navigator.clipboard.writeText(text)

    this.feedbackTarget.textContent = "Copied!"
    this.feedbackTarget.classList.add("success")

    setTimeout(() => {
      this.feedbackTarget.textContent = "Click to copy"
      this.feedbackTarget.classList.remove("success")
    }, this.timeoutValue || 2000)
  }
}
ERB
<div data-controller="clipboard" data-clipboard-timeout-value="3000">
  <code data-clipboard-target="source">
    rails new myapp
  </code>

  <button data-action="click->clipboard#copy">
    Copy
  </button>

  <span data-clipboard-target="feedback">Click to copy</span>
</div>

Targets, Actions, Values

Concept Purpose Example
Controllers Organize JavaScript clipboard_controller.js
Targets Reference specific elements this.sourceTarget
Actions Event handlers click->clipboard#copy
Values Configuration data timeoutValue: 3000

Real-World Examples

Form Validation

JAVASCRIPT
// app/javascript/controllers/form_validation_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["field", "error"]

  validate() {
    const field = this.fieldTarget
    const error = this.errorTarget

    if (field.value.length < 3) {
      error.textContent = "Must be at least 3 characters"
      error.classList.add("visible")
      field.classList.add("invalid")
    } else {
      error.textContent = ""
      error.classList.remove("visible")
      field.classList.remove("invalid")
    }
  }
}
ERB
<div data-controller="form-validation">
  <input
    data-controller-target="field"
    data-action="input->form-validation#validate"
    type="text"
  >
  <span data-controller-target="error" class="error"></span>
</div>

Toggle

JAVASCRIPT
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  toggle() {
    this.contentTarget.classList.toggle("hidden")
  }
}
ERB
<div data-controller="toggle">
  <button data-action="click->toggle#toggle">
    Show Details
  </button>

  <div data-controller-target="content" class="hidden">
    More information...
  </div>
</div>

Infinite Scroll

JAVASCRIPT
// app/javascript/controllers/infinite_scroll_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["sentinel"]

  connect() {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.loadMore()
      }
    })

    observer.observe(this.sentinelTarget)
  }

  loadMore() {
    this.dispatch("load")
  }
}
ERB
<div data-controller="infinite-scroll">
  <div id="posts">
    <!-- Existing posts -->
  </div>

  <div data-infinite-scroll-target="sentinel">
    Loading more...
  </div>
</div>

Part 7: Real-World Examples

Problem: Search results should update as the user types.

Solution: Turbo Frames with lazy loading.

ERB
<%# app/views/products/index.html.erb %>
<%= form_with url: products_path, method: :get, data: { turbo_frame: "search_results" } do |f| %>
  <%= f.text_field :query, placeholder: "Search products...",
    data: { action: "input->search#search" } %>
<% end %>

<%= turbo_frame_tag "search_results", src: products_path(query: params[:query]) do %>
  <!-- Results will load here -->
<% end %>
JAVASCRIPT
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  search(event) {
    const frame = document.getElementById("search_results")
    frame.src = `/products?query=${event.target.value}`
    frame.reload()
  }
}

Example 2: Like Button

Problem: Clicking like should update the count without refreshing.

Solution: Turbo Streams.

RUBY
# app/controllers/likes_controller.rb
def create
  @post = Post.find(params[:post_id])
  @post.likes.create(user: current_user)

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.replace("like_#{@post.id}", partial: "like")
    end
  end
end
ERB
<%# app/views/posts/_like.html.erb %>
<div id="like_<%= post.id %>">
  <% if current_user&.liked?(post) %>
    <%= button_to "Unlike", like_path(post), method: :delete, data: { turbo: true } %>
  <% else %>
    <%= button_to "Like", likes_path(post_id: post.id), data: { turbo: true } %>
  <% end %>
  <span><%= post.likes.count %> likes</span>
</div>

Example 3: Notifications

Problem: New notifications should appear in real-time.

Solution: Turbo Streams over WebSockets.

RUBY
# app/models/notification.rb
class Notification < ApplicationRecord
  after_create_commit do
    broadcast_prepend_to "notifications_#{user_id}",
      partial: "notifications/notification",
      locals: { notification: self }
  end
end
ERB
<%# app/views/layouts/application.html.erb %>
<%= turbo_stream_from "notifications_#{current_user.id}" %>

<div id="notifications">
  <%= render current_user.notifications.unread %>
</div>

Part 8: When to Use Hotwire vs React

Decision Framework

Use Hotwire Use React
CRUD apps Complex UI like Figma
Internal tools Real-time collaboration
MVP and prototypes Offline-first apps
Most B2B SaaS Mobile apps (React Native)
Solo developer Large frontend team
Fast shipping App-like experience

Real-World Examples

Hotwire Success Stories

  • Basecamp: Built entirely with Hotwire
  • HEY: Email client with Hotwire
  • GitHub: Uses Turbo for navigation
  • Shopify: Checkout with Turbo

React Success Stories

  • Notion: Complex document editing
  • Figma: Real-time collaboration
  • Linear: App-like experience
  • Discord: Real-time messaging

Part 9: Common Gotchas

Gotcha 1: Missing CSRF Token

Problem: Forms fail with ActionController::InvalidAuthenticityToken.

Solution: Set CSRF meta tag.

ERB
<%= csrf_meta_tags %>

Gotcha 2: Turbo and JavaScript Events

Problem: JavaScript runs once but not after Turbo navigation.

Solution: Use Turbo events.

JAVASCRIPT
// Instead of DOMContentLoaded
document.addEventListener("turbo:load", function() {
  // Your code here
});

// For re-initializing after navigation
document.addEventListener("turbo:before-cache", function() {
  // Clean up before caching
});

Gotcha 3: Form Submissions

Problem: Forms submit twice or don't work.

Solution: Check form behavior.

ERB
<%# Disable Turbo for specific forms %>
<%= form_with model: @user, data: { turbo: false } do |f| %>

<%# Or use local: true for old behavior %>
<%= form_with model: @user, local: true do |f| %>

Gotcha 4: Partial Updates

Problem: Entire page updates instead of just the part you want.

Solution: Use correct frame IDs.

ERB
<%# Make sure IDs match %>
<%= turbo_frame_tag "comments" do %>
  <!-- Content -->
<% end %>

<%# Update from controller %>
<%= turbo_stream.replace "comments", partial: "comments" %>

Part 10: Performance Tips

Optimize Turbo Drive

JAVASCRIPT
// Preload links
document.addEventListener("turbo:click", function(event) {
  // Show loading indicator
});

Cache Turbo Frames

ERB
<%# Cache frame content %>
<%= turbo_frame_tag "expensive_content", src: expensive_path, loading: :lazy %>

Use Deferred Loading

ERB
<%# Load content only when needed %>
<%= turbo_frame_tag "dashboard_stats", src: stats_path, loading: :lazy do %>
  <div class="spinner">Loading...</div>
<% end %>

Summary

Hotwire makes Rails frontends fast and simple.

Tool Purpose
Turbo Drive Faster page navigation
Turbo Frames Update specific parts of pages
Turbo Streams Real-time updates
Stimulus JavaScript behavior

Quick Start

BASH
# Rails 7 includes Hotwire by default
rails new myapp --javascript=importmap

# Generate a Stimulus controller
rails generate stimulus clipboard

# Add Turbo Frames
<%= turbo_frame_tag "list" do %>

# Add Turbo Streams
<%= turbo_stream.append "comments" do %>

The Bottom Line

You don't need React for most Rails apps.

Turbo and Stimulus handle complex interfaces with far less code. One language. One codebase. No separate frontend.

Try it on your next feature. You might be surprised how much you can build without adding a JavaScript framework.


DO

Derrick Orare

Software Engineer

Discussion

0 Comments

Questions, feedback or corrections are welcome.

💬

No discussion yet.

Be the first to share your thoughts.

Leave a comment