Skip to main content
Technology & EngineeringRuby Rails329 lines

Stimulus

Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.

Quick Summary36 lines
You are an expert in Stimulus.js for adding lightweight, progressive JavaScript behavior to Rails applications.

## Key Points

- Keep controllers small and single-purpose. Prefer composing multiple controllers on an element over building monolithic ones.
- Use `values` for configuration passed from the server. Avoid hardcoding URLs or text in JavaScript.
- Use `classes` for CSS class names so the controller is not coupled to a specific design system.
- Clean up timers, observers, and event listeners in `disconnect()`.
- Name controllers by behavior (e.g., `toggle`, `clipboard`, `auto-submit`), not by the UI element they control.
- Prefer `data-action` attributes in HTML over `addEventListener` in `connect()`.
- Combine Stimulus with Turbo Frames and Streams; let the server handle data while Stimulus handles UI interactions.
- **Forgetting to register controllers**: With importmap or esbuild auto-loading, ensure the file is named correctly (e.g., `hello_controller.js` maps to `data-controller="hello"`).
- **Target not found errors**: If a target may not exist, check `this.hasXxxTarget` before accessing it.
- **Memory leaks**: Not cleaning up intervals or observers in `disconnect()` causes leaks, especially with Turbo navigation.
- **Overusing Stimulus**: If you need complex client-side state management, Stimulus may not be the right tool. Consider whether Turbo Streams can handle the update from the server instead.
- **Mixing Stimulus with inline scripts**: Avoid `<script>` tags in partials that Turbo swaps. Use Stimulus controllers instead.

## Quick Example

```erb
<div data-controller="greeting" data-greeting-greeting-value="Hi">
  <input data-greeting-target="name" type="text" placeholder="Your name">
  <button data-action="click->greeting#greet">Greet</button>
  <p data-greeting-target="output"></p>
</div>
```

```erb
<div data-controller="poller"
     data-poller-url-value="<%= notifications_path %>"
     data-poller-refresh-interval-value="3000"
     data-poller-active-value="true">
</div>
```
skilldb get ruby-rails-skills/StimulusFull skill: 329 lines
Paste into your CLAUDE.md or agent config

Stimulus Controllers — Ruby on Rails

You are an expert in Stimulus.js for adding lightweight, progressive JavaScript behavior to Rails applications.

Overview

Stimulus is a modest JavaScript framework that connects behavior to server-rendered HTML using data attributes. It complements Turbo by handling interactions that require client-side logic -- toggling visibility, form validation, copy-to-clipboard, and similar patterns -- without building a full SPA.

Core Philosophy

Stimulus exists because not every interaction needs a server round-trip, but most interactions do not need a JavaScript framework either. It fills the gap between static HTML and full single-page applications by providing just enough structure to attach behavior to server-rendered markup. The HTML is the source of truth; Stimulus simply makes it interactive.

The design principle behind Stimulus is that behavior should be declared in the HTML, not hidden in JavaScript files. Data attributes like data-controller, data-action, and data-target make it visible in the template exactly which JavaScript behaviors are attached to which elements. This means a developer reading the HTML can understand the page's interactivity without context-switching to a separate codebase.

Controllers should be small, reusable, and composable. A toggle controller, a clipboard controller, and a debounce controller can each be written once and composed on any element. The moment a Stimulus controller starts managing complex state, fetching data, or coordinating multiple API calls, it has outgrown Stimulus and should either be handled server-side with Turbo or moved to a dedicated JavaScript framework.

Anti-Patterns

  • Monolithic Controllers: Building a single controller with dozens of targets, values, and methods that manages an entire page section. Stimulus controllers should be small and single-purpose. If a controller name is something like dashboard_controller.js, it is probably doing too much.

  • Reimplementing Turbo in Stimulus: Writing Stimulus controllers that fetch HTML from the server and swap DOM content manually. This is exactly what Turbo Frames and Streams are designed for. Use Turbo for server-driven updates and reserve Stimulus for client-only interactions.

  • Hardcoded Values in JavaScript: Embedding URLs, API keys, CSS class names, or translatable strings directly in controller code instead of passing them as values and classes from the HTML. This couples your JavaScript to your application's routing and design system.

  • Event Listener Leaks: Adding event listeners in connect() without removing them in disconnect(). Because Turbo navigations mount and unmount controllers frequently, leaked listeners accumulate and cause performance degradation or duplicate event handling.

  • Bypassing the Action System: Using addEventListener inside controllers instead of declaring actions with data-action attributes in the HTML. This hides behavior from anyone reading the markup and loses the declarative clarity that is Stimulus's primary advantage.

Core Concepts

Controller Lifecycle

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

export default class extends Controller {
  static targets = ["name", "output"]
  static values = { greeting: { type: String, default: "Hello" } }
  static classes = ["active"]

  connect() {
    // Called when the controller's element appears in the DOM
    console.log("Greeting controller connected")
  }

  disconnect() {
    // Called when the controller's element is removed from the DOM
  }

  greet() {
    this.outputTarget.textContent =
      `${this.greetingValue}, ${this.nameTarget.value}!`
  }
}
<div data-controller="greeting" data-greeting-greeting-value="Hi">
  <input data-greeting-target="name" type="text" placeholder="Your name">
  <button data-action="click->greeting#greet">Greet</button>
  <p data-greeting-target="output"></p>
</div>

Targets

Targets provide typed references to important DOM elements within the controller scope.

import { Controller } from "@hotwired/stimulus"

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

  // Generated methods:
  // this.itemTarget       - first matching element (throws if missing)
  // this.itemTargets      - array of all matching elements
  // this.hasItemTarget    - boolean check

  updateCount() {
    this.countTarget.textContent = this.itemTargets.length
  }
}

Values

Values are typed reactive properties read from data attributes.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String,
    refreshInterval: { type: Number, default: 5000 },
    active: Boolean
  }

  // Auto-generated: this.urlValue, this.refreshIntervalValue, this.activeValue
  // Change callbacks are called when the data attribute changes:
  urlValueChanged() {
    this.fetchData()
  }

  activeValueChanged() {
    this.element.classList.toggle("is-active", this.activeValue)
  }
}
<div data-controller="poller"
     data-poller-url-value="<%= notifications_path %>"
     data-poller-refresh-interval-value="3000"
     data-poller-active-value="true">
</div>

Actions

Actions connect DOM events to controller methods.

<%# Standard click action %>
<button data-action="click->modal#open">Open</button>

<%# Multiple actions on one element %>
<input data-action="input->search#query focus->search#expand blur->search#collapse">

<%# Keyboard events %>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">

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

<%# Window and document events %>
<div data-controller="nav" data-action="scroll@window->nav#highlight">
</div>

Implementation Patterns

Toggle Controller

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

export default class extends Controller {
  static targets = ["content"]
  static classes = ["hidden"]

  toggle() {
    this.contentTargets.forEach(target => {
      target.classList.toggle(this.hiddenClass)
    })
  }

  show() {
    this.contentTargets.forEach(t => t.classList.remove(this.hiddenClass))
  }

  hide() {
    this.contentTargets.forEach(t => t.classList.add(this.hiddenClass))
  }
}
<div data-controller="toggle" data-toggle-hidden-class="hidden">
  <button data-action="toggle#toggle">Toggle Details</button>
  <div data-toggle-target="content" class="hidden">
    <p>Detailed information here.</p>
  </div>
</div>

Debounced Search

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

export default class extends Controller {
  static targets = ["input", "results"]
  static values = { url: String, delay: { type: Number, default: 300 } }

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.fetchResults()
    }, this.delayValue)
  }

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

    const url = `${this.urlValue}?q=${encodeURIComponent(query)}`
    const response = await fetch(url, {
      headers: { "Accept": "text/vnd.turbo-stream.html" }
    })

    if (response.ok) {
      const html = await response.text()
      this.resultsTarget.innerHTML = html
    }
  }

  disconnect() {
    clearTimeout(this.timeout)
  }
}

Clipboard Controller

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

export default class extends Controller {
  static targets = ["source", "button"]
  static values = { successDuration: { type: Number, default: 2000 } }

  async copy() {
    const text = this.sourceTarget.value || this.sourceTarget.textContent
    await navigator.clipboard.writeText(text.trim())

    const original = this.buttonTarget.textContent
    this.buttonTarget.textContent = "Copied!"
    setTimeout(() => {
      this.buttonTarget.textContent = original
    }, this.successDurationValue)
  }
}

Form Auto-Submit

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

export default class extends Controller {
  static values = { delay: { type: Number, default: 500 } }

  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.element.requestSubmit()
    }, this.delayValue)
  }

  disconnect() {
    clearTimeout(this.timeout)
  }
}
<%= form_with url: search_path, method: :get,
    data: { controller: "auto-submit", turbo_frame: "results" } do |f| %>
  <%= f.select :category, options,
      data: { action: "change->auto-submit#submit" } %>
  <%= f.search_field :q,
      data: { action: "input->auto-submit#submit" } %>
<% end %>

Controller Communication with Events

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

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

  connect() {
    this.element.addEventListener("cart:updated", this.refresh.bind(this))
  }

  refresh(event) {
    this.countTarget.textContent = event.detail.itemCount
    this.totalTarget.textContent = event.detail.total
  }
}

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

export default class extends Controller {
  add() {
    // After adding item...
    this.dispatch("updated", {
      detail: { itemCount: 5, total: "$49.99" },
      prefix: "cart",
      target: document.querySelector("[data-controller='cart']")
    })
  }
}

Best Practices

  • Keep controllers small and single-purpose. Prefer composing multiple controllers on an element over building monolithic ones.
  • Use values for configuration passed from the server. Avoid hardcoding URLs or text in JavaScript.
  • Use classes for CSS class names so the controller is not coupled to a specific design system.
  • Clean up timers, observers, and event listeners in disconnect().
  • Name controllers by behavior (e.g., toggle, clipboard, auto-submit), not by the UI element they control.
  • Prefer data-action attributes in HTML over addEventListener in connect().
  • Combine Stimulus with Turbo Frames and Streams; let the server handle data while Stimulus handles UI interactions.

Common Pitfalls

  • Forgetting to register controllers: With importmap or esbuild auto-loading, ensure the file is named correctly (e.g., hello_controller.js maps to data-controller="hello").
  • Target not found errors: If a target may not exist, check this.hasXxxTarget before accessing it.
  • Memory leaks: Not cleaning up intervals or observers in disconnect() causes leaks, especially with Turbo navigation.
  • Overusing Stimulus: If you need complex client-side state management, Stimulus may not be the right tool. Consider whether Turbo Streams can handle the update from the server instead.
  • Mixing Stimulus with inline scripts: Avoid <script> tags in partials that Turbo swaps. Use Stimulus controllers instead.

Install this skill directly: skilldb add ruby-rails-skills

Get CLI access →