Hotwire 〰 in Rails 8 World – And How My New Rails App Puts this into Work 🚀

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:

  1. Turbo – the engine that intercepts normal links/forms, keeps your page state alive, and swaps HTML frames or streams into the DOM at 60fps.
  2. Stimulus – a “sprinkle-on” JavaScript framework for the little interactive bits that still need JS (dropdowns, clipboard buttons, etc.).
  3. (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:

  1. Controller action responds to turbo_stream:
respond_to do |format|
  ...
  format.turbo_stream   # <-- returns delete_image.turbo_stream.erb
end
  1. Stream template sent back:
# app/views/products/delete_image.turbo_stream.erb

<turbo-stream action="remove" target="product-image-<%= @image_id %>"></turbo-stream>
  1. Turbo receives the <turbo-stream> tag, finds the element with that id, 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_stream when you ask for format.turbo_stream in 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

  1. More Turbo Frames – Wrap parts of pages in <turbo-frame id="cart"> to make only the cart refresh on “Add to cart”.
  2. Broadcasting – Hook Product model changes to turbo_stream_from channels so that all users see live stock updates.
  3. 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:

  1. turbo-rails JS (loaded via import “@hotwired/turbo-rails”) cancels the browser’s default navigation.
  2. Turbo sends an AJAX request (actually fetch()) for the new URL, requesting full HTML.
  3. The response text is parsed into an off-screen DOMParser document.
  4. 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).
  5. 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! 🚀

Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a comment