Rails Hotwire
Master Hotwire for building modern, reactive Rails applications using Turbo and Stimulus without requiring heavy JavaScript frameworks.
Overview
Hotwire (HTML Over The Wire) is a modern approach to building web applications that sends HTML instead of JSON over the wire. It consists of Turbo (for delivering server-rendered HTML) and Stimulus (for JavaScript sprinkles).
Installation and Setup
Installing Hotwire
Add to Gemfile
bundle add turbo-rails stimulus-rails
Install Turbo
rails turbo:install
Install Stimulus
rails stimulus:install
Install Redis for ActionCable (Turbo Streams)
bundle add redis
Configure ActionCable
rails generate channel turbo_stream
Configuration
config/cable.yml
development: adapter: redis url: redis://localhost:6379/1
production: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: myapp_production
config/routes.rb
Rails.application.routes.draw do mount ActionCable.server => '/cable' end
Core Patterns
- Turbo Drive (Page Acceleration)
Turbo Drive is automatic, but you can customize behavior
app/views/layouts/application.html.erb
<!DOCTYPE html> <html> <head> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> </head> <body> <%= yield %> </body> </html>
Disable Turbo for specific links
<%= link_to "Legacy Page", legacy_path, data: { turbo: false } %>
Disable Turbo for forms
<%= form_with url: upload_path, data: { turbo: false } do |f| %> <%= f.file_field :document %> <% end %>
Custom progress bar
<style> .turbo-progress-bar { background: linear-gradient(to right, #4ade80, #3b82f6); } </style>
- Turbo Frames (Lazy Loading & Decomposition)
app/views/posts/index.html.erb
<div id="posts"> <% @posts.each do |post| %> <%= turbo_frame_tag dom_id(post) do %> <%= render post %> <% end %> <% end %> </div>
app/views/posts/_post.html.erb
<article> <h2><%= post.title %></h2> <p><%= post.body %></p>
<%= link_to "Edit", edit_post_path(post) %> <%= link_to "Delete", post_path(post), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %> </article>
app/views/posts/edit.html.erb
<%= turbo_frame_tag dom_id(@post) do %> <%= form_with model: @post do |f| %> <%= f.text_field :title %> <%= f.text_area :body %> <%= f.submit %> <% end %> <% end %>
Lazy loading frames
<%= turbo_frame_tag "analytics", src: analytics_path, loading: :lazy do %> <p>Loading analytics...</p> <% end %>
Target different frames
<%= link_to "Show Post", post_path(post), data: { turbo_frame: "modal" } %>
Break out of frame
<%= link_to "New Page", new_post_path, data: { turbo_frame: "_top" } %>
- Turbo Streams (Real-time Updates)
app/controllers/posts_controller.rb
class PostsController < ApplicationController def create @post = Post.new(post_params)
respond_to do |format|
if @post.save
format.turbo_stream
format.html { redirect_to @post }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy @post = Post.find(params[:id]) @post.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(@post) }
format.html { redirect_to posts_path }
end
end end
app/views/posts/create.turbo_stream.erb
<%= turbo_stream.prepend "posts", partial: "posts/post", locals: { post: @post } %> <%= turbo_stream.update "new_post", "" %> <%= turbo_stream.replace "flash", partial: "shared/flash", locals: { message: "Post created!" } %>
Multiple Turbo Stream actions
<%= turbo_stream.append "notifications" do %> <div class="notification">New post created!</div> <% end %>
<%= turbo_stream.update "post_count", Post.count %>
<%= turbo_stream.remove "loading_spinner" %>
<%= turbo_stream.replace dom_id(@post), partial: "posts/post", locals: { post: @post } %>
- Broadcasting Updates
app/models/post.rb
class Post < ApplicationRecord broadcasts_to ->(post) { [post.user, "posts"] }, inserts_by: :prepend
Or more explicit
after_create_commit -> { broadcast_prepend_to "posts", partial: "posts/post", locals: { post: self }, target: "posts" }
after_update_commit -> { broadcast_replace_to "posts", partial: "posts/post", locals: { post: self }, target: dom_id(self) }
after_destroy_commit -> { broadcast_remove_to "posts", target: dom_id(self) } end
app/views/posts/index.html.erb
<%= turbo_stream_from "posts" %>
<div id="posts"> <%= render @posts %> </div>
Broadcast to specific users
class Comment < ApplicationRecord belongs_to :post
after_create_commit -> { broadcast_prepend_to [post.user, :comments], partial: "comments/comment", locals: { comment: self }, target: "comments" } end
app/views/posts/show.html.erb
<%= turbo_stream_from current_user, :comments %>
- Stimulus Controllers
// app/javascript/controllers/clipboard_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["source", "button"] static values = { successMessage: String, errorMessage: String }
copy(event) { event.preventDefault()
navigator.clipboard.writeText(this.sourceTarget.value).then(
() => this.showSuccess(),
() => this.showError()
)
}
showSuccess() { this.buttonTarget.textContent = this.successMessageValue || "Copied!" setTimeout(() => { this.buttonTarget.textContent = "Copy" }, 2000) }
showError() { this.buttonTarget.textContent = this.errorMessageValue || "Failed!" } }
<!-- app/views/posts/show.html.erb --> <div data-controller="clipboard" data-clipboard-success-message-value="Copied to clipboard!"> <input type="text" value="<%= @post.share_url %>" data-clipboard-target="source" readonly> <button data-clipboard-target="button" data-action="click->clipboard#copy"> Copy </button> </div>
- Form Validation with Stimulus
// app/javascript/controllers/form_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["email", "password", "submit"] static classes = ["error"]
connect() { this.validateForm() }
validateField(event) { const field = event.target const isValid = field.checkValidity()
if (isValid) {
field.classList.remove(this.errorClass)
} else {
field.classList.add(this.errorClass)
}
this.validateForm()
}
validateForm() { const isValid = this.element.checkValidity() this.submitTarget.disabled = !isValid }
async submit(event) { event.preventDefault()
if (!this.element.checkValidity()) {
return
}
const formData = new FormData(this.element)
const response = await fetch(this.element.action, {
method: this.element.method,
body: formData,
headers: {
"Accept": "text/vnd.turbo-stream.html"
}
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
} }
<%= form_with model: @user, data: { controller: "form", form_error_class: "border-red-500" } do |f| %>
<%= f.email_field :email, required: true, data: { form_target: "email", action: "blur->form#validateField" } %>
<%= f.password_field :password, required: true, minlength: 8, data: { form_target: "password", action: "blur->form#validateField" } %>
<%= f.submit "Sign Up", data: { form_target: "submit", action: "click->form#submit" } %> <% end %>
- Infinite Scroll
// app/javascript/controllers/infinite_scroll_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["entries", "pagination"] static values = { url: String, page: Number }
initialize() { this.scroll = this.scroll.bind(this) }
connect() { this.createObserver() }
disconnect() { this.observer.disconnect() }
createObserver() { this.observer = new IntersectionObserver( entries => this.handleIntersect(entries), { threshold: 1.0 } ) this.observer.observe(this.paginationTarget) }
handleIntersect(entries) { entries.forEach(entry => { if (entry.isIntersecting) { this.loadMore() } }) }
async loadMore() { const url = this.paginationTarget.querySelector("a[rel='next']")?.href
if (!url) return
this.pageValue++
const response = await fetch(url, {
headers: {
Accept: "text/vnd.turbo-stream.html"
}
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
} }
<!-- app/views/posts/index.html.erb --> <div data-controller="infinite-scroll"> <div id="posts" data-infinite-scroll-target="entries"> <%= render @posts %> </div>
<div data-infinite-scroll-target="pagination"> <%= paginate @posts %> </div> </div>
<!-- app/views/posts/index.turbo_stream.erb --> <%= turbo_stream.append "posts" do %> <%= render @posts %> <% end %>
<%= turbo_stream.replace "pagination" do %> <%= paginate @posts %> <% end %>
- Modal Dialogs
// app/javascript/controllers/modal_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["container", "backdrop"]
connect() { document.body.classList.add("overflow-hidden") }
disconnect() { document.body.classList.remove("overflow-hidden") }
close(event) { if (event.target === this.backdropTarget || event.currentTarget.dataset.closeModal === "true") { this.element.remove() } }
closeWithKeyboard(event) { if (event.key === "Escape") { this.element.remove() } } }
<!-- app/views/posts/_modal.html.erb --> <div data-controller="modal" data-action="keyup@window->modal#closeWithKeyboard" class="fixed inset-0 z-50">
<div data-modal-target="backdrop" data-action="click->modal#close" class="fixed inset-0 bg-black bg-opacity-50"></div>
<div data-modal-target="container" class="fixed inset-0 flex items-center justify-center"> <div class="bg-white rounded-lg p-6 max-w-lg"> <%= turbo_frame_tag "modal_content" do %> <%= yield %> <% end %>
<button data-close-modal="true"
data-action="click->modal#close">
Close
</button>
</div>
</div> </div>
<!-- Trigger modal --> <%= link_to "Edit Post", edit_post_path(@post), data: { turbo_frame: "modal" } %>
- Autosave with Stimulus
// app/javascript/controllers/autosave_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["status"] static values = { delay: { type: Number, default: 1000 }, url: String }
connect() { this.timeout = null this.saving = false }
save() { clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.persist()
}, this.delayValue)
}
async persist() { if (this.saving) return
this.saving = true
this.showStatus("Saving...")
const formData = new FormData(this.element)
try {
const response = await fetch(this.urlValue, {
method: "PATCH",
body: formData,
headers: {
"X-CSRF-Token": document.querySelector("[name='csrf-token']").content,
"Accept": "application/json"
}
})
if (response.ok) {
this.showStatus("Saved", "success")
} else {
this.showStatus("Error saving", "error")
}
} catch (error) {
this.showStatus("Error saving", "error")
} finally {
this.saving = false
}
}
showStatus(message, type = "info") {
this.statusTarget.textContent = message
this.statusTarget.className = status-${type}
setTimeout(() => {
this.statusTarget.textContent = ""
}, 2000)
} }
<%= form_with model: @post, data: { controller: "autosave", autosave_url_value: post_path(@post), action: "input->autosave#save" } do |f| %>
<div data-autosave-target="status"></div>
<%= f.text_field :title %> <%= f.text_area :body %> <% end %>
- Search with Debouncing
// app/javascript/controllers/search_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["input", "results"] static values = { url: String, delay: { type: Number, default: 300 } }
connect() { this.timeout = null }
search() { clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.performSearch()
}, this.delayValue)
}
async performSearch() { const query = this.inputTarget.value
if (query.length < 2) {
this.resultsTarget.innerHTML = ""
return
}
const url = new URL(this.urlValue)
url.searchParams.set("q", query)
const response = await fetch(url, {
headers: {
Accept: "text/vnd.turbo-stream.html"
}
})
if (response.ok) {
const html = await response.text()
Turbo.renderStreamMessage(html)
}
}
clear() { this.inputTarget.value = "" this.resultsTarget.innerHTML = "" } }
<div data-controller="search" data-search-url-value="<%= search_posts_path %>">
<input type="text" data-search-target="input" data-action="input->search#search" placeholder="Search posts...">
<button data-action="click->search#clear">Clear</button>
<div id="search-results" data-search-target="results"></div> </div>
Best Practices
-
Use Turbo Frames for isolation - Scope updates to specific parts
-
Broadcast model changes - Keep all clients synchronized
-
Progressive enhancement - Ensure functionality without JavaScript
-
Lazy load frames - Improve initial page load performance
-
Use Stimulus for sprinkles - Keep JavaScript minimal and focused
-
Leverage Turbo Streams - Update multiple parts of the page
-
Handle errors gracefully - Provide fallbacks for network issues
-
Cache appropriately - Use HTTP caching with Turbo
-
Test real-time features - Verify broadcasts work correctly
-
Optimize database queries - Prevent N+1 with includes/preload
Common Pitfalls
-
Over-using Turbo Frames - Not everything needs to be a frame
-
Missing CSRF tokens - Forgetting tokens in AJAX requests
-
Race conditions - Not handling concurrent broadcasts
-
Memory leaks - Not disconnecting ActionCable subscriptions
-
Flash message issues - Flash persisting across Turbo requests
-
Breaking browser history - Improper Turbo navigation
-
SEO concerns - Not considering search engine crawlers
-
Form state loss - Losing unsaved data on navigation
-
Accessibility issues - Not managing focus and ARIA attributes
-
Over-engineering - Using Hotwire when simple HTML suffices
When to Use
-
Building modern Rails applications
-
Creating real-time collaborative features
-
Implementing live updates without polling
-
Building single-page-like experiences
-
Reducing JavaScript complexity
-
Progressive enhancement scenarios
-
Mobile-friendly responsive interfaces
-
Admin dashboards with live data
-
Chat and messaging applications
-
Live notifications and feeds
Resources
-
Hotwire Documentation
-
Turbo Handbook
-
Stimulus Handbook
-
Turbo Rails Gem
-
Stimulus Components
-
GoRails Hotwire Tutorials