/ Tags: RAILS / Categories: RAILS

Stimulus Controllers — Adding JavaScript Behavior to Rails Views Without a SPA

The appeal of server-rendered Rails has always been that you write less — less duplication between client and server, less state management, less JavaScript infrastructure. But “less JavaScript” doesn’t mean no JavaScript. Dropdowns need to open. Character counters need to update. Tabs need to switch. Stimulus is the Hotwire library designed for exactly this: small, scoped JavaScript controllers that attach to server-rendered HTML without taking over the page.

What Stimulus Is — and Isn’t


Stimulus is not a frontend framework. It doesn’t manage state, handle routing, or own the DOM. It connects JavaScript behavior to HTML you already wrote. The key concepts are controllers (the JavaScript classes), targets (the DOM elements a controller manages), actions (the events that trigger controller methods), and values (typed data stored in HTML attributes that the controller can read and react to).

The HTML is the source of truth. Stimulus controllers observe it and add behavior on top.

Setup:

# Included in Rails 7+ via importmap by default
# For manual setup:
bin/importmap pin @hotwired/stimulus

# Or with Node/bundler:
yarn add @hotwired/stimulus
// app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"
const application = Application.start()
export { application }

// app/javascript/controllers/index.js
import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

Your First Controller


Controllers are plain ES6 classes that extend Controller. They connect to HTML via data-controller attributes, and Stimulus handles the lifecycle automatically.

Example:

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

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

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

  close() {
    this.menuTarget.classList.add("hidden")
  }
}
<!-- app/views/layouts/_nav.html.erb -->
<div data-controller="dropdown">
  <button data-action="click->dropdown#toggle">
    Settings
  </button>

  <div data-dropdown-target="menu" class="hidden">
    <%= link_to "Profile", profile_path %>
    <%= link_to "Sign out", session_path, method: :delete %>
  </div>
</div>

The data-controller="dropdown" attribute connects the HTML element to the DropdownController class. data-action="click->dropdown#toggle" wires the click event to the toggle method. data-dropdown-target="menu" registers the element as a target that the controller can access via this.menuTarget.

No manual querySelector. No event listener setup. No teardown code.

Targets — Accessing DOM Elements


Targets let controllers reference specific child elements without fragile CSS selectors.

Example:

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

export default class extends Controller {
  static targets = ["input", "count", "warning"]

  connect() {
    this.updateCount()
  }

  updateCount() {
    const length  = this.inputTarget.value.length
    const max     = parseInt(this.inputTarget.getAttribute("maxlength")) || 280
    const remaining = max - length

    this.countTarget.textContent = `${remaining} characters remaining`
    this.warningTarget.classList.toggle("text-red-600", remaining < 20)
  }
}
<div data-controller="character-counter">
  <%= f.text_area :bio,
      maxlength: 280,
      data: { "character-counter-target": "input",
              action: "input->character-counter#updateCount" } %>
  <span data-character-counter-target="count"></span>
  <span data-character-counter-target="warning"></span>
</div>

this.inputTarget gives you the first element with data-character-counter-target="input". Use this.inputTargets (plural) to get all matching targets as an array.

Values — Typed State in HTML Attributes


Values store typed configuration data in HTML data-* attributes. Stimulus handles type coercion and provides change callbacks automatically.

Example:

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

export default class extends Controller {
  static targets = ["display"]
  static values  = {
    duration: { type: Number, default: 60 },
    running:  { type: Boolean, default: false }
  }

  connect() {
    this.remaining = this.durationValue
    this.updateDisplay()
  }

  start() {
    if (this.runningValue) return
    this.runningValue = true
    this.interval = setInterval(() => this.tick(), 1000)
  }

  stop() {
    this.runningValue = false
    clearInterval(this.interval)
  }

  tick() {
    this.remaining -= 1
    this.updateDisplay()
    if (this.remaining <= 0) this.stop()
  }

  updateDisplay() {
    this.displayTarget.textContent = `${this.remaining}s`
  }
}
<div data-controller="timer"
     data-timer-duration-value="120">
  <span data-timer-target="display"></span>
  <button data-action="click->timer#start">Start</button>
  <button data-action="click->timer#stop">Stop</button>
</div>

Values are type-checked by Stimulus. Change the duration value in the DOM and the controller sees the correct number type — no manual parsing.

Actions — Connecting Events to Methods


The data-action attribute format is eventName->controllerName#methodName. Multiple actions can be chained with spaces.

Example:

<!-- Single action -->
<button data-action="click->form#submit">Submit</button>

<!-- Multiple actions on same element -->
<input data-action="input->search#query keydown.enter->search#select"
       data-search-target="input">

<!-- Form submit -->
<form data-action="submit->form#validate">

<!-- Global events (window/document) -->
<div data-controller="keyboard"
     data-action="keydown.escape@window->keyboard#close">
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results"]

  query() {
    const term = this.inputTarget.value.trim()
    if (term.length < 2) return

    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => this.fetchResults(term), 300)
  }

  async fetchResults(term) {
    const response = await fetch(`/search?q=${encodeURIComponent(term)}`, {
      headers: { "Accept": "text/html" }
    })
    this.resultsTarget.innerHTML = await response.text()
  }

  select(event) {
    event.preventDefault()
    this.inputTarget.value = event.currentTarget.dataset.value
    this.resultsTarget.innerHTML = ""
  }
}

Lifecycle Callbacks


Stimulus provides lifecycle callbacks for connecting to and disconnecting from the DOM — important for setting up timers, subscriptions, and third-party libraries.

Example:

// app/javascript/controllers/chart_controller.js
import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"

export default class extends Controller {
  static targets = ["canvas"]
  static values  = { data: Array, label: String }

  connect() {
    this.chart = new Chart(this.canvasTarget, {
      type: "line",
      data: {
        labels: this.dataValue.map((_, i) => `Day ${i + 1}`),
        datasets: [{
          label: this.labelValue,
          data: this.dataValue
        }]
      }
    })
  }

  disconnect() {
    this.chart?.destroy()  // Cleanup on navigation — prevents memory leaks
  }
}
<div data-controller="chart"
     data-chart-data-value="<%= @revenue_data.to_json %>"
     data-chart-label-value="Revenue">
  <canvas data-chart-target="canvas"></canvas>
</div>

disconnect is called when the element is removed from the DOM — critical for anything that holds resources outside the element (timers, WebSockets, third-party library instances).

Pro-Tip: Keep Stimulus controllers small and single-purpose. If a controller is growing past 50-60 lines, it’s usually doing two things. Split it. The naming convention reinforces this — a DropdownController handles dropdown behavior and nothing else. When controllers stay focused, they become reusable across the application without modification. One generic ModalController that renders different server-fetched content is more useful than a ProjectModalController and an InvoiceModalController that duplicate the same open/close logic.

Conclusion


Stimulus adds JavaScript behavior to Rails views without replacing the server-rendering model that makes Rails fast to build with. Controllers stay small, HTML stays the source of truth, and behavior stays attached to the elements that need it. Combined with Turbo for navigation and frames, Stimulus covers the JavaScript requirements of most Rails applications without the complexity overhead of a single-page application framework.

FAQs


Q1: When should I use Stimulus vs writing a Turbo Stream?
Turbo Streams are for DOM updates driven by server responses — broadcasting changes, replacing sections after a form submit. Stimulus is for client-side behavior that doesn’t need a server round-trip — toggling visibility, updating character counts, managing a local timer. Use both together for rich interactions.

Q2: Can I use Stimulus with Rails 6 or older?
Yes. Stimulus works with any server-rendered HTML. Install it via yarn or npm and configure your bundler. The importmap-rails integration makes it zero-config on Rails 7+.

Q3: How do I share state between two Stimulus controllers on the same page?
Via custom events. Controller A dispatches this.dispatch("someEvent", { detail: { data } }) and Controller B listens with data-action="someEvent->controllerB#handle". This keeps controllers decoupled without a shared state store.

Q4: Does Stimulus work with Turbo’s page navigation?
Yes — this is one of Stimulus’s key design goals. connect() and disconnect() fire correctly on Turbo Drive navigation and Turbo Frame updates. Cleanup in disconnect() prevents resource leaks across page visits.

Q5: Is there a way to test Stimulus controllers?
Yes. Use @hotwired/stimulus-testing for unit tests, or integration test via Capybara/Selenium. Testing the controller behavior through the HTML interface (manipulating the DOM and checking outcomes) is generally more maintainable than testing controller methods directly.

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