Hotwire: Turbo and Stimulus

Ruby on Rails · Practical · 16 min

Hotwire is Rails' default front end: rich interactivity without writing or maintaining a JavaScript SPA.

Hotwire sends HTML, not JSON, over the wire. It has two parts installed by default.

Turbo handles navigation and updates:

  • Turbo Drive intercepts link clicks and form submissions, fetches the next page and swaps the body without a full reload. Failed form submissions must return a 4xx status (422) so Turbo renders the response. Turbo 8 can morph pages on refresh (turbo_refreshes_with method: :morph), keeping scroll and focus.
  • Turbo Frames (<%= turbo_frame_tag dom_id(article) do %>) make a region update independently: links and forms inside a frame replace only that frame with the matching frame from the response. Lazy-load a frame with src: and loading: :lazy.
  • Turbo Streams apply targeted changes: append, prepend, replace, update, remove, before, after, refresh. Respond with them from a controller (format.turbo_stream) or broadcast them to every subscriber over WebSockets from a model with broadcasts_to or broadcast_append_to, which uses Action Cable (Solid Cable by default).

Stimulus adds small bits of JavaScript behaviour to server-rendered HTML. A controller is a JS class connected through data attributes: data-controller="clipboard", data-action="click->clipboard#copy" and data-clipboard-target="source". State lives in the DOM, so controllers keep working after Turbo replaces content.

The workflow: start with plain server-rendered pages, add Frames for in-place editing, Streams for live updates, and Stimulus only for what HTML can't do.

Example

<%# app/views/comments/_comment.html.erb %>
<%= turbo_frame_tag dom_id(comment) do %>
  <p><%= comment.body %> <%= link_to "Edit", edit_comment_path(comment) %></p>
<% end %>
<%# Edit renders a form inside the same frame id: only this comment is replaced %>

<%# app/views/articles/show.html.erb %>
<%= turbo_stream_from @article %>          <%# subscribe to live updates %>
<div id="comments"><%= render @article.comments %></div>

<%# app/models/comment.rb:
    after_create_commit -> { broadcast_append_to article, target: "comments" } %>

<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "comments/form" %>
<%# Stimulus: app/javascript/controllers/clipboard_controller.js
    export default class extends Controller {
      static targets = ["source"]
      copy() { navigator.clipboard.writeText(this.sourceTarget.value) }
    } %>
<div data-controller="clipboard">
  <input data-clipboard-target="source" value="<%= article_url(@article) %>" readonly>
  <button data-action="click->clipboard#copy">Copy link</button>
</div>

Remember

  • Turbo Drive makes navigation fast; failed forms must return 422
  • Frames update one region; Streams append, replace or remove elements, even live via broadcasts
  • Stimulus connects small JS controllers to HTML with data attributes

Watch out for

  • Returning 200 for a failed form submission, so Turbo ignores the re-rendered form with errors.

Check yourself

  1. A link inside turbo_frame_tag "comment_5" is clicked. What does Turbo do with the response?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: turbo.hotwired.dev/handbook/introduction

Builds on

Unlocks

Nothing builds on this yet.