Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 021 / 100

Stimulus controllers: a Ruby on Rails guide for developers

Sprinkles of JavaScript.

Expona Admin7 min read

Introduction

When Rails developers first encounter Stimulus, they often see it as a lightweight alternative to heavier JavaScript frameworks. In reality, Stimulus controllers: a Ruby on Rails guide for developers reveals a philosophy that shifts how you think about interactivity on the server-rendered web. Rather than building separate frontend applications, Stimulus lets you sprinkle JavaScript directly into your Rails views, connecting HTML elements to behavior through a simple convention-based system.

Over the next 100 days, we're building practical knowledge across the Rails stack. Today, we're examining Stimulus as the bridge between your Rails backend and the interactive experiences your users expect. If you've been writing inline JavaScript or wondering how to organize client-side logic without a full JavaScript framework, this guide will show you a better path.

data-controller connects behavior

The foundation of Stimulus is the data-controller attribute. This single HTML attribute establishes a connection between your markup and a JavaScript controller class. Think of it as declaring: "this element and its children need this specific behavior."

Let's say you're building a comment moderation interface for a community platform. Moderators need to approve or reject comments without leaving the page. Here's how data-controller makes that work:

ruby
<div data-controller="comment-moderator">
  <div class="comment">
    <p><%= comment.body %></p>
    <button data-action="click->comment-moderator#approve">Approve</button>
    <button data-action="click->comment-moderator#reject">Reject</button>
  </div>
</div>

The controller file lives at app/javascript/controllers/comment_moderator_controller.js. Stimulus uses a naming convention: hyphens in HTML become underscores in file names. This convention eliminates the need for manual registration.

ruby
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    console.log("Comment moderator controller connected")
  }

  approve() {
    this.submitModerationAction("approve")
  }

  reject() {
    this.submitModerationAction("reject")
  }

  submitModerationAction(action) {
    // Send to Rails backend
  }
}

The connect() lifecycle method runs automatically when Stimulus initializes the controller. This happens when the page loads or when Rails Turbo inserts new HTML. Use it for setup that runs once per controller instance.

One key insight: multiple controllers can be attached to the same element. If you need both a modal behavior and form validation on one dialog, stack them:

ruby
<div data-controller="modal form-validator">
  <!-- content -->
</div>

Stimulus will instantiate both controllers independently. Each has its own lifecycle and state, avoiding conflicts.

Targets and actions in HTML

Targets let you reference specific elements within your controller. Instead of querying the DOM with selectors, you declare which elements matter using data-{controller}-target. This makes your JavaScript code more resilient to markup changes and clearer about intent.

Consider a form where users upload files and see progress. You need to update a progress bar and a status message. Rather than hunting for elements with IDs or classes, use targets:

ruby
<div data-controller="file-uploader">
  <input type="file" data-file-uploader-target="input" />
  <div class="progress">
    <div 
      class="progress-bar" 
      data-file-uploader-target="progressBar"
      style="width: 0%"></div>
  </div>
  <p data-file-uploader-target="status">Ready</p>
</div>

In the controller, you access targets via this.{targetName}Target for singular or this.{targetName}Targets for arrays:

ruby
import { Controller } from "@hotwired/stimulus"

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

  uploadFile() {
    const file = this.inputTarget.files[0]
    
    const formData = new FormData()
    formData.append("file", file)

    fetch("/api/upload", {
      method: "POST",
      body: formData
    })
    .then(response => response.json())
    .then(data => {
      this.progressBarTarget.style.width = "100%"
      this.statusTarget.textContent = "Upload complete"
    })
    .catch(error => {
      this.statusTarget.textContent = "Upload failed"
    })
  }
}

Actions bind user interactions to controller methods. The syntax data-action="event->controller#method" is declarative and easy to scan. When you see a button, you immediately know what happens when clicked.

Common events include click, submit, change, and keyup. You can attach multiple actions to one element:

ruby
<input 
  type="text" 
  data-action="keyup->search#filter change->search#reset"
  placeholder="Search products"
/>

This input triggers filter on every keyup and reset when the value changes via other means (paste, selection, etc.).

Values for configuration

Stimulus values provide a way to pass configuration from HTML to JavaScript without storing data in the DOM or using hidden inputs. They're reactive: when a value changes, getters and setters give you hooks to respond.

Imagine a notification component that shows or hides based on a severity level set on the server:

ruby
<div 
  data-controller="notification" 
  data-notification-severity-value="error"
  data-notification-auto-dismiss-value="true"
  data-notification-duration-value="5000">
  <p data-notification-target="message">Something went wrong</p>
</div>

The controller declares these values upfront and uses them in initialization:

ruby
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["message"]
  static values = { 
    severity: String,
    autoDismiss: Boolean,
    duration: Number
  }

  connect() {
    this.applyStyles()
    
    if (this.autoDismissValue) {
      setTimeout(() => this.dismiss(), this.durationValue)
    }
  }

  applyStyles() {
    const colors = {
      error: "#dc3545",
      warning: "#ffc107",
      success: "#28a745"
    }
    this.element.style.borderLeft = `4px solid ${colors[this.severityValue]}`
  }

  dismiss() {
    this.element.remove()
  }
}

Values support type coercion: String, Number, Boolean, and Object. If your HTML has data-notification-duration-value="5000", Stimulus automatically converts it to the number 5000 based on your value declaration.

You can also update values from JavaScript, which triggers re-renders. This is useful when a user interaction should change the controller's behavior:

ruby
updateSeverity(newLevel) {
  this.severityValue = newLevel // triggers change callback
}

To respond to value changes, define a callback method named {valueName}ValueChanged:

ruby
severityValueChanged() {
  this.applyStyles()
  console.log("Severity changed to:", this.severityValue)
}

Keep controllers small

The most common mistake teams make with Stimulus is treating controllers as mini single-page applications. Controllers should be focused, single-purpose, and under 200 lines of code when possible. When you feel a controller growing beyond that, it's time to split.

In an e-commerce platform, you might build a product filter. Resist the urge to handle search, sorting, pagination, and favorites in one controller. Instead, use Stimulus's composition model:

ruby
<div data-controller="product-browser">
  <aside data-controller="filter-panel">
    <!-- Filter UI -->
  </aside>
  
  <div data-controller="sort-controls">
    <!-- Sort buttons -->
  </div>

  <div data-controller="product-list pagination">
    <!-- Products and pagination -->
  </div>
</div>

Each controller owns its own piece of the interface. They communicate through Rails itself: when filter-panel sends a fetch request to your backend, the server returns new HTML, Rails Turbo updates the page, and Stimulus re-initializes affected controllers. This keeps logic on the server where state is authoritative.

If controllers absolutely must coordinate, use browser events:

ruby
// In filter-panel controller
applyFilters() {
  this.element.dispatchEvent(
    new CustomEvent("filters-updated", { 
      detail: { category: this.selectedCategory } 
    })
  )
}

// In product-list controller
connect() {
  document.addEventListener("filters-updated", event => {
    console.log("Filters changed:", event.detail)
  })
}

This approach keeps controllers decoupled. They don't need to know about each other; they just listen for events that interest them.

A practical size guideline: if you're writing more than 150 lines in a single controller file, review whether you're mixing concerns. Methods should be focused and task-specific. If a method spans more than 15-20 lines, extract helper methods or consider whether logic belongs in a separate controller.

Common mistakes

Using data-controller for page-level state: Don't try to replace a state management system with Stimulus. Controllers are meant for UI behavior tied to specific DOM elements. If you need to track global application state or share complex data across many controllers, consider whether you need a different tool or whether that logic should live in Rails.

Forgetting about Turbo interactions: When Rails Turbo navigates to a new page or updates a fragment, Stimulus controllers are automatically connected and disconnected. Leaving event listeners or intervals running is a memory leak. Always clean up in a disconnect callback:

ruby
connect() {
  this.handleScroll = this.scrollHandler.bind(this)
  window.addEventListener("scroll", this.handleScroll)
}

disconnect() {
  window.removeEventListener("scroll", this.handleScroll)
}

Overly complex HTML selectors in JavaScript: If you find yourself writing this.element.querySelector('.parent .sibling .deep .nested .target'), stop. Add a target attribute instead. This couples your JavaScript to CSS class names, making refactoring brittle.

Assuming controllers are singletons: Each controller can have multiple instances. If you attach a controller to several elements on the same page, each gets its own instance with its own state. Don't use class variables to share state between instances unless that's explicitly your intent.

Key takeaways

  • data-controller connects HTML elements to Stimulus controllers via convention-based naming, eliminating manual registration.
  • Targets provide resilient element references without coupling to CSS class names or IDs.
  • Actions declare user interactions in HTML, making behavior visible at a glance and easier to refactor.
  • Values pass configuration from the server and support reactive updates through change callbacks.
  • Keep controllers focused and small; compose complex behavior from multiple lightweight controllers working together.
  • Always clean up event listeners and timers in disconnect callbacks to prevent memory leaks when Turbo navigates.
  • Let Rails handle state and data logic; Stimulus enhances the UI layer without replacing backend responsibilities.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles