/ Tags: RAILS / Categories: RAILS

Stimulus.js — Adding Interactivity to Hotwire Without a Frontend Framework

Turbo handles navigation and real-time updates beautifully — page transitions, stream updates, frame replacement, all without writing JavaScript. But there’s always that next layer of requirement: a dropdown that opens and closes, a character counter on a textarea, a form that conditionally reveals extra fields. Turbo doesn’t touch that. Stimulus.js does. It’s the missing piece of Hotwire that keeps your Rails app interactive without reaching for React or Vue.

What Stimulus Actually Is


Stimulus is a modest JavaScript framework — its own word, and accurate. It doesn’t manage state. It doesn’t render templates. It doesn’t own the DOM. It connects small pieces of behavior to HTML elements using data- attributes, and it gets out of the way.

The mental model is simple: you write a controller class in JavaScript, attach it to an HTML element with data-controller, and Stimulus wires them together automatically. When the element appears in the DOM, the controller connects. When it leaves, the controller disconnects. No setup, no teardown, no event listener leaks.

This model fits Rails views naturally — your ERB generates the HTML, Stimulus adds the behavior. You stay in Rails. The HTML stays readable. The JavaScript stays small.

Setting It Up


In a Rails 7+ app with Importmap (the default), Stimulus is already included. If you’re using a bundler like esbuild:

Setup:

bin/rails stimulus:install

This adds the @hotwired/stimulus package and creates app/javascript/controllers/index.js which auto-imports any controllers in that directory.

Check app/javascript/controllers/index.js:

Example:

import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

Any file you drop in app/javascript/controllers/ with the naming convention *_controller.js gets picked up automatically.

Your First Controller


The classic example: a toggle. Click a button, show or hide a panel.

Setup:

bin/rails generate stimulus toggle

Example:

// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["panel"]

  toggle() {
    this.panelTarget.classList.toggle("hidden")
  }
}

Example:

<div data-controller="toggle">
  <button data-action="click->toggle#toggle">Show/Hide</button>
  <div data-toggle-target="panel" class="hidden">
    This panel toggles.
  </div>
</div>

Three concepts, that’s the whole framework:

  • data-controller — identifies the element and which controller class to use
  • data-action — maps DOM events to controller methods (click->toggle#toggle)
  • data-*-target — lets the controller reference specific child elements

No getElementById. No querySelector. No event listeners scattered through your JavaScript. The HTML is the wiring diagram.

Values and State


Stimulus controllers can hold state using typed values, which sync automatically between the controller and the element’s data- attributes.

Example:

// app/javascript/controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { count: { type: Number, default: 0 } }
  static targets = ["display"]

  increment() {
    this.countValue++
  }

  countValueChanged() {
    this.displayTarget.textContent = this.countValue
  }
}

Example:

<div data-controller="counter" data-counter-count-value="0">
  <button data-action="click->counter#increment">+1</button>
  <span data-counter-target="display">0</span>
</div>

Every time countValue changes, Stimulus automatically calls countValueChanged. You don’t manage re-rendering — you just update the value and describe what should happen when it changes. This pattern keeps your controllers reactive without a full state management library.

Working with Turbo Together


Stimulus and Turbo are designed to coexist. When Turbo replaces part of the page (via a Turbo Frame or Stream), Stimulus handles the connect/disconnect lifecycle automatically for any controllers in the affected area.

Example:

# app/views/posts/_form.html.erb
<%= form_with model: @post, data: { controller: "character-count" } do |f| %>
  <%= f.text_area :body,
        data: {
          "character-count-target": "input",
          action: "input->character-count#update"
        } %>
  <span data-character-count-target="counter">0 / 500</span>
<% end %>
// app/javascript/controllers/character_count_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "counter"]
  static values  = { max: { type: Number, default: 500 } }

  update() {
    const remaining = this.maxValue - this.inputTarget.value.length
    this.counterTarget.textContent = `${this.inputTarget.value.length} / ${this.maxValue}`
    this.counterTarget.classList.toggle("text-red-500", remaining < 0)
  }
}

No lifecycle hooks needed. Turbo navigates, Stimulus reconnects, the counter initializes itself from the DOM. It just works.

Pro-Tip: Keep controllers small and single-purpose. The instinct when you first learn Stimulus is to build big controllers that manage multiple concerns. Resist it. A character-count controller, a toggle controller, a clipboard controller — each does one thing and stays under 30 lines. When you find yourself passing data between controllers, use Stimulus’s outlet API or Turbo Streams to coordinate, not a shared controller God object.

Using Stimulus Across the App


Controllers defined in app/javascript/controllers/ are available everywhere. A dropdown controller built once works on any dropdown in any view. This is one of Stimulus’s quiet advantages over inline JavaScript: behavior is centralized and reusable without a component system.

For common patterns — modals, tooltips, clipboard, lazy loading — the Stimulus community maintains stimulus-components, a library of pre-built controllers you can drop in:

Setup:

bin/importmap pin stimulus-components

Saves you from writing the same toggle controller for the fifth project in a row.

Conclusion


Stimulus earns its place in the Hotwire stack by staying in its lane. It doesn’t try to be a full frontend framework, and that restraint is exactly what makes it fit Rails so well. You write HTML that expresses intent through data- attributes, and Stimulus wires up behavior that stays in sync with the DOM automatically. Combined with Turbo for navigation and real-time updates, you can build surprisingly rich interfaces without ever reaching for a separate frontend framework. For most Rails apps, that’s not a compromise — it’s the right call.

FAQs


Q1: Do I need Stimulus if I’m already using Turbo?
Turbo handles page transitions and DOM updates from the server. Stimulus handles client-side behavior — dropdowns, counters, form interactions. They solve different problems and are designed to work together.

Q2: How does Stimulus compare to Alpine.js?
Alpine.js is directive-based and lives entirely in HTML attributes — lower JavaScript overhead, less structure. Stimulus uses actual JavaScript classes, which makes complex behavior more maintainable. For simple interactions, either works; for anything requiring real logic or multiple elements, Stimulus scales better.

Q3: Can Stimulus controllers communicate with each other?
Yes, via the Outlets API (static outlets = ["other-controller"]), which gives one controller a reference to another. For cross-controller coordination that involves server data, Turbo Streams are usually the cleaner option.

Q4: What happens to Stimulus controllers during Turbo navigation?
Stimulus tracks the lifecycle of controllers automatically. When Turbo replaces a page section, controllers in the removed area disconnect and controllers in the new area connect. You don’t need to manage this manually.

Q5: Is Stimulus suitable for complex SPAs?
Not really, and it doesn’t try to be. For apps where the frontend is the primary product and state management complexity is high, a dedicated framework like React makes more sense. Stimulus is built for Rails apps that want interactivity without leaving the server-rendered model.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More