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 withsrc:andloading: :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 withbroadcasts_toorbroadcast_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.