Turbo and Hotwire
Hotwire is Rails' approach to building modern, fast web applications with minimal JavaScript. It consists of Turbo (for navigation and updates) and Stimulus (for JavaScript interactions).
Turbo Drive
Turbo Drive accelerates page loads by converting link clicks and form submissions into AJAX requests:
erb
1<!-- Links are automatically enhanced -->
2<%= link_to "Articles", articles_path %>
3
4<!-- Disable Turbo for specific link -->
5<%= link_to "External", "https://example.com", data: { turbo: false } %>
6
7<!-- Forms are also enhanced -->
8<%= form_with model: @article do |f| %>
9 <%= f.text_field :title %>
10 <%= f.submit %>
11<% end %>
12
13<!-- Disable Turbo for form -->
14<%= form_with model: @article, data: { turbo: false } do |f| %>Turbo Drive Events
javascript
1// Listen for Turbo events
2document.addEventListener("turbo:load", () => {
3 console.log("Page loaded via Turbo")
4})
5
6document.addEventListener("turbo:before-visit", (event) => {
7 console.log("About to visit:", event.detail.url)
8})
9
10document.addEventListener("turbo:submit-start", (event) => {
11 console.log("Form submission started")
12})Turbo Frames
Turbo Frames allow you to update specific parts of the page:
erb
1<!-- app/views/articles/index.html.erb -->
2<h1>Articles</h1>
3
4<%= turbo_frame_tag "articles" do %>
5 <% @articles.each do |article| %>
6 <%= render article %>
7 <% end %>
8
9 <%= link_to "Load More", articles_path(page: @page + 1) %>
10<% end %>Lazy Loading Frames
erb
1<!-- Load content lazily -->
2<%= turbo_frame_tag "comments", src: article_comments_path(@article), loading: :lazy do %>
3 <p>Loading comments...</p>
4<% end %>Targeting Frames
erb
1<!-- Link updates a specific frame -->
2<%= turbo_frame_tag "article_#{@article.id}" do %>
3 <h2><%= @article.title %></h2>
4 <%= link_to "Edit", edit_article_path(@article) %>
5<% end %>
6
7<!-- In edit view, wrap in same frame -->
8<%= turbo_frame_tag "article_#{@article.id}" do %>
9 <%= render "form", article: @article %>
10<% end %>Breaking Out of Frames
erb
1<!-- Link targets the whole page -->
2<%= link_to "View Full", @article, data: { turbo_frame: "_top" } %>
3
4<!-- Or target a different frame -->
5<%= link_to "Preview", preview_article_path(@article), data: { turbo_frame: "preview_panel" } %>Turbo Streams
Turbo Streams enable real-time updates to the page:
Stream Actions
erb
1<!-- Append to a container -->
2<%= turbo_stream.append "articles", @article %>
3
4<!-- Prepend -->
5<%= turbo_stream.prepend "articles", @article %>
6
7<!-- Replace element -->
8<%= turbo_stream.replace @article %>
9
10<!-- Update (replace content, keep element) -->
11<%= turbo_stream.update @article %>
12
13<!-- Remove element -->
14<%= turbo_stream.remove @article %>
15
16<!-- Before/After -->
17<%= turbo_stream.before @article, partial: "articles/article", locals: { article: @new_article } %>
18<%= turbo_stream.after @article, partial: "articles/article", locals: { article: @new_article } %>Controller Response
ruby
1class CommentsController < ApplicationController
2 def create
3 @article = Article.find(params[:article_id])
4 @comment = @article.comments.build(comment_params)
5
6 if @comment.save
7 respond_to do |format|
8 format.turbo_stream
9 format.html { redirect_to @article }
10 end
11 else
12 render :new, status: :unprocessable_entity
13 end
14 end
15enderb
1<!-- app/views/comments/create.turbo_stream.erb -->
2<%= turbo_stream.prepend "comments", @comment %>
3<%= turbo_stream.update "new_comment_form" do %>
4 <%= render "form", comment: Comment.new, article: @article %>
5<% end %>
6<%= turbo_stream.update "comments_count", @article.comments.count %>Inline Turbo Streams
ruby
1def destroy
2 @comment = Comment.find(params[:id])
3 @comment.destroy
4
5 respond_to do |format|
6 format.turbo_stream { render turbo_stream: turbo_stream.remove(@comment) }
7 format.html { redirect_to @comment.article }
8 end
9endStimulus Controllers
Stimulus adds JavaScript behavior to HTML elements:
Basic Controller
javascript
1// app/javascript/controllers/hello_controller.js
2import { Controller } from "@hotwired/stimulus"
3
4export default class extends Controller {
5 static targets = ["name", "output"]
6
7 greet() {
8 this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`
9 }
10}erb
1<div data-controller="hello">
2 <input data-hello-target="name" type="text">
3 <button data-action="click->hello#greet">Greet</button>
4 <span data-hello-target="output"></span>
5</div>Common Stimulus Patterns
javascript
1// Toggle controller
2import { Controller } from "@hotwired/stimulus"
3
4export default class extends Controller {
5 static targets = ["content"]
6 static classes = ["hidden"]
7
8 toggle() {
9 this.contentTarget.classList.toggle(this.hiddenClass)
10 }
11}erb
1<div data-controller="toggle" data-toggle-hidden-class="hidden">
2 <button data-action="click->toggle#toggle">Toggle</button>
3 <div data-toggle-target="content">
4 Content to toggle
5 </div>
6</div>javascript
1// Form validation controller
2import { Controller } from "@hotwired/stimulus"
3
4export default class extends Controller {
5 static targets = ["submit", "email"]
6
7 validate() {
8 const valid = this.emailTarget.value.includes("@")
9 this.submitTarget.disabled = !valid
10 }
11}erb
1<form data-controller="validation">
2 <input type="email"
3 data-validation-target="email"
4 data-action="input->validation#validate">
5 <button data-validation-target="submit" disabled>Submit</button>
6</form>Values and Classes
javascript
1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4 static values = {
5 url: String,
6 refreshInterval: { type: Number, default: 5000 }
7 }
8
9 connect() {
10 this.startRefresh()
11 }
12
13 startRefresh() {
14 setInterval(() => {
15 fetch(this.urlValue)
16 .then(response => response.text())
17 .then(html => this.element.innerHTML = html)
18 }, this.refreshIntervalValue)
19 }
20}erb
1<div data-controller="auto-refresh"
2 data-auto-refresh-url-value="<%= notifications_path %>"
3 data-auto-refresh-refresh-interval-value="10000">
4</div>Real-Time with Action Cable
ruby
1# app/channels/comments_channel.rb
2class CommentsChannel < ApplicationCable::Channel
3 def subscribed
4 stream_from "article_#{params[:article_id]}_comments"
5 end
6end
7
8# Broadcast from model
9class Comment < ApplicationRecord
10 after_create_commit -> {
11 broadcast_prepend_to "article_#{article_id}_comments",
12 target: "comments",
13 partial: "comments/comment"
14 }
15enderb
1<%= turbo_stream_from "article_#{@article.id}_comments" %>
2<div id="comments">
3 <%= render @article.comments %>
4</div>Hotwire lets you build dynamic interfaces with minimal JavaScript!
