When you create a brand-new Rails 8 project today you automatically get a super-powerful front-end toolbox called Hotwire.
Because it is baked into the framework, it can feel a little magical (“everything just works!”). This post demystifies Hotwire, shows how its two core libraries—Turbo and Stimulus—fit together, and then walks through the places where the design_studio codebase is already using them.
1. What is Hotwire?
Hotwire (HTML Over The Wire) is a set of conventions + JavaScript libraries that lets you build modern, reactive UIs without writing (much) custom JS or a separate SPA. Instead of pushing JSON to the browser and letting a JS framework patch the DOM, the server sends HTML fragments over WebSockets, SSE, or normal HTTP responses and the browser swaps them in efficiently.
Hotwire is made of three parts:
- Turbo – the engine that intercepts normal links/forms, keeps your page state alive, and swaps HTML frames or streams into the DOM at 60fps.
- Stimulus – a “sprinkle-on” JavaScript framework for the little interactive bits that still need JS (dropdowns, clipboard buttons, etc.).
- (Optional) Strada – native-bridge helpers for mobile apps; not relevant to our web-only project.
Because Rails 8 ships with both turbo-rails and stimulus-rails gems, simply creating a project wires everything up.
2. How Turbo & Stimulus complement each other
- Turbo keeps pages fresh – It handles navigation (
Turbo Drive), partial page updates via<turbo-frame>(Turbo Frames), and real-time broadcasts with<turbo-stream>(Turbo Streams). - Stimulus adds behaviour – Tiny ES-module controllers attach to DOM elements and react to events/data attributes. Importantly, Stimulus plays nicely with Turbo’s DOM-swapping because controllers automatically disconnect/re-connect when elements are replaced.
Think of Turbo as the transport layer for HTML and Stimulus as the behaviour layer for the small pieces that still need JavaScript logic.
# server logs - still identify as HTML request, It handles navigation through (Turbo Drive)
Started GET "/products/15" for ::1 at 2025-06-24 00:47:03 +0530
Processing by ProductsController#show as HTML
Parameters: {"id" => "15"}
.......
Started GET "/products?category=women" for ::1 at 2025-06-24 00:50:38 +0530
Processing by ProductsController#index as HTML
Parameters: {"category" => "women"}
.......
Javascript and css files that loads in our html head:
<link rel="stylesheet" href="/assets/actiontext-e646701d.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/application-8b441ae0.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/tailwind-8bbb1409.css" data-turbo-track="reload" />
<script type="importmap" data-turbo-track="reload">{
"imports": {
"application": "/assets/application-3da76259.js",
"@hotwired/turbo-rails": "/assets/turbo.min-3a2e143f.js",
"@hotwired/stimulus": "/assets/stimulus.min-4b1e420e.js",
"@hotwired/stimulus-loading": "/assets/stimulus-loading-1fc53fe7.js",
"trix": "/assets/trix-4b540cb5.js",
"@rails/actiontext": "/assets/actiontext.esm-f1c04d34.js",
"controllers/application": "/assets/controllers/application-3affb389.js",
"controllers/hello_controller": "/assets/controllers/hello_controller-708796bd.js",
"controllers": "/assets/controllers/index-ee64e1f1.js"
}
}</script>
<link rel="modulepreload" href="/assets/application-3da76259.js">
<link rel="modulepreload" href="/assets/turbo.min-3a2e143f.js">
<link rel="modulepreload" href="/assets/stimulus.min-4b1e420e.js">
<link rel="modulepreload" href="/assets/stimulus-loading-1fc53fe7.js">
<link rel="modulepreload" href="/assets/trix-4b540cb5.js">
<link rel="modulepreload" href="/assets/actiontext.esm-f1c04d34.js">
<link rel="modulepreload" href="/assets/controllers/application-3affb389.js">
<link rel="modulepreload" href="/assets/controllers/hello_controller-708796bd.js">
<link rel="modulepreload" href="/assets/controllers/index-ee64e1f1.js">
<script type="module">import "application"</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
3. Where Hotwire lives in design_studio
Because Rails 8 scaffolded most of this for us, the integration is scattered across a few key spots:
3.1 Gems & ES-modules are pinned
# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
The Gemfile pulls the Ruby wrappers:
gem "turbo-rails"
gem "stimulus-rails"
3.2 Global JavaScript entry point
# application.js
import "@hotwired/turbo-rails"
import "controllers" // <-- auto-registers everything in app/javascript/controllers
As soon as that file is imported (it’s linked in application.html.erb via javascript_include_tag "application", "data-turbo-track": "reload"
), Turbo intercepts every link & form on the site.
3.3 Stimulus controllers
The framework-generated controller registry lives at app/javascript/controllers/index.js; the only custom controller so far is the hello-world example:
connect() {
this.element.textContent = "Hello World!"
}
You can drop new controllers into app/javascript/controllers/anything_controller.js and they will be auto-loaded thanks to the pin_all_from line above.
pin_all_from "app/javascript/controllers", under: "controllers"
3.4 Turbo Streams in practice – removing a product image
The most concrete Hotwire interaction in design_studio today is the “Delete image” action in the products feature:
- Controller action responds to
turbo_stream:
respond_to do |format|
...
format.turbo_stream # <-- returns delete_image.turbo_stream.erb
end
- Stream template sent back:
# app/views/products/delete_image.turbo_stream.erb
<turbo-stream action="remove" target="product-image-<%= @image_id %>"></turbo-stream>
- Turbo receives the
<turbo-stream>tag, finds the element with thatid, and removes it from the DOM—no page reload, no hand-written JS.
# app/views/products/show.html.erb
....
<%= link_to @product,
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this product?" },
class: "px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors duration-200" do %>
<i class="fas fa-trash mr-2"></i>Delete Product
<% end %>
....
3.5 “Free” Turbo benefits you might not notice
Because Turbo Drive is on globally:
- Standard links look instantaneous (HTML diffing & cache).
- Form submissions automatically request
.turbo_streamwhen you ask forformat.turbo_streamin a controller. - Redirects keep scroll position/head tags in sync.
All of this happens without any code in the repo—Rails 8 + Turbo does the heavy lifting.
4. Extending Hotwire in the future
- More Turbo Frames – Wrap parts of pages in
<turbo-frame id="cart">to make only the cart refresh on “Add to cart”. - Broadcasting – Hook
Productmodel changes toturbo_stream_fromchannels so that all users see live stock updates. - Stimulus components – Replace jQuery snippets with small controllers (dropdowns, modals, copy-to-clipboard, etc.).
Because everything is wired already (Importmap, controller autoloading, Cable), adding these features is mostly a matter of creating the HTML/ERB templates and a bit of Ruby.
Questions
1. Is Rails 8 still working with the real DOM?
- Yes, the browser is always working with the real DOM—nothing is virtualized (unlike React’s virtual DOM).
- Turbo intercepts navigation events (links, form submits). Instead of letting the browser perform a “hard” navigation, it fetches the HTML with fetch() in the background, parses the response into a hidden document fragment, then swaps specific pieces (usually the whole <body> or a <turbo-frame> target) into the live DOM.
- Because Turbo only swaps the changed chunks, it keeps the rest of the page alive (JS state, scroll position, playing videos, etc.) and fires lifecycle events so Stimulus controllers disconnect/re-connect cleanly.
“Stimulus itself is a tiny wrapper around MutationObserver. It attaches controller instances to DOM elements and tears them down automatically when Turbo replaces those elements—so both libraries cooperate rather than fighting the DOM.”
2. How does the HTML from Turbo Drive get into the DOM without a full reload?
Step-by-step for a normal link click:
- turbo-rails JS (loaded via import “@hotwired/turbo-rails”) cancels the browser’s default navigation.
- Turbo sends an AJAX request (actually fetch()) for the new URL, requesting full HTML.
- The response text is parsed into an off-screen DOMParser document.
- Turbo compares the <head> tags, updates <title> and any changed assets, then replaces the <body> of the current page with the new one (or, for <turbo-frame>, just that frame).
- It pushes a history.pushState entry so Back/Forward work, and fires events like turbo:load.
Because no real navigation happened, the browser doesn’t clear JS state, WebSocket connections, or CSS; it just swaps some DOM nodes—visually it feels instantaneous.
3. What does pin mean in config/importmap.rb?
Rails 8 ships with Importmap—a way to use normal ES-module import statements without a bundler.pin is simply a mapping declaration:
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
Meaning:
- When the browser sees
import "@hotwired/turbo-rails", fetch…/assets/turbo.min.js - When it sees import “controllers”, look at
pin_all_from "app/javascript/controllers"
which expands into individual mappings for every controller file.
Think of pin as the importmap equivalent of a require statement in a bundler config—just declarative and handled at runtime by the browser. That’s all there is to it: real DOM, no page reloads, and a lightweight way to load JS modules without Webpack.
Take-aways
- Hotwire is not one big library; it is a philosophy (+ Turbo + Stimulus) that keeps most of your UI in Ruby & ERB but still feels snappy and modern.
- Rails 8 scaffolds everything, so you may not even realize you’re using it—but you are!
- design_studio already benefits from Hotwire’s defaults (fast navigation) and uses Turbo Streams for dynamic image deletion. The plumbing is in place to expand this pattern across the app with minimal effort.
Happy hot-wiring! 🚀