Skip
Arish's avatar

40. Turbo and Hotwire


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
15end
erb
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
9end

Stimulus 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  }
15end
erb
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!