Rails Development

Stimulus Controllers: Adding Just Enough JavaScript

After building real-time features with Turbo Streams, I thought I was done with JavaScript. But some interactions just feel better with a touch of client-side behavior—smooth animations, instant feedback, keyboard shortcuts, or form enhancements.

That's where Stimulus comes in. It's the JavaScript framework that doesn't feel like a JavaScript framework. In this post, I'll show you how Stimulus lets you add sprinkles of interactivity without abandoning Rails' server-side approach.

The Stimulus Philosophy

Stimulus follows a simple principle: HTML is the source of truth. Instead of JavaScript controlling everything, you use HTML data attributes to tell JavaScript what to do.

Compare this to traditional JavaScript frameworks:

Traditional Approach

// JavaScript controls everything
const modal = new Modal({
  trigger: '#open-modal',
  content: '#modal-content',
  closeable: true,
  backdrop: true
});

Stimulus Approach

<!-- HTML declares behavior -->
<button data-controller="modal" 
        data-action="click->modal#open"
        data-modal-closeable-value="true">
  Open Modal
</button>

<div data-modal-target="content" class="hidden">
  Modal content here
</div>

The HTML describes what should happen, and Stimulus controllers provide the behavior.

Setting Up Stimulus

If you're using Rails 7+, Stimulus is already included. For older versions:

# Add to Gemfile
gem 'stimulus-rails'

# Run installer
rails stimulus:install

Your First Stimulus Controller

Let's start with a simple example: making a collapsible content area.

Create the Controller

rails generate stimulus collapse

This creates app/javascript/controllers/collapse_controller.js:

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

export default class extends Controller {
  static targets = ["content", "button"]

  toggle() {
    if (this.contentTarget.classList.contains("hidden")) {
      this.expand()
    } else {
      this.collapse()
    }
  }

  expand() {
    this.contentTarget.classList.remove("hidden")
    this.buttonTarget.textContent = "Collapse"
  }

  collapse() {
    this.contentTarget.classList.add("hidden")
    this.buttonTarget.textContent = "Expand"
  }
}

Use in Your View

<!-- app/views/tasks/_task.html.erb -->
<div class="task" data-controller="collapse">
  <div class="task-header">
    <h3><%= task.title %></h3>
    <button data-collapse-target="button" 
            data-action="click->collapse#toggle"
            class="btn-collapse">
      Expand
    </button>
  </div>

  <div data-collapse-target="content" class="hidden">
    <p><%= task.description %></p>
    <p><em>Created: <%= task.created_at.strftime("%B %d, %Y") %></em></p>
  </div>
</div>

What's happening:
- data-controller="collapse" connects the HTML to the JavaScript controller
- data-collapse-target="content" identifies elements the controller can reference
- data-action="click->collapse#toggle" tells Stimulus what method to call on what event

Understanding Stimulus Concepts

1. Controllers

Controllers are JavaScript classes that handle a specific piece of functionality:

export default class extends Controller {
  // Controller behavior goes here
}

2. Targets

Targets are important elements your controller needs to reference:

static targets = ["content", "button", "form"]

// Creates these properties:
// this.contentTarget (first matching element)
// this.contentTargets (all matching elements)
// this.hasContentTarget (boolean)

3. Actions

Actions connect events to controller methods:

<!-- Format: event->controller#method -->
data-action="click->modal#open"
data-action="submit->form#validate"
data-action="keydown->search#filter"

4. Values

Values let you pass data from HTML to JavaScript:

static values = { 
  delay: Number, 
  message: String, 
  enabled: Boolean 
}

// Access via this.delayValue, this.messageValue, etc.
<div data-controller="auto-save" 
     data-auto-save-delay-value="5000"
     data-auto-save-message-value="Saving...">

Practical Stimulus Examples

1. Auto-Save Forms

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

export default class extends Controller {
  static targets = ["form", "status"]
  static values = { delay: Number }

  connect() {
    this.timeout = null
    this.delayValue = this.delayValue || 3000
  }

  save() {
    clearTimeout(this.timeout)

    this.timeout = setTimeout(() => {
      this.submitForm()
    }, this.delayValue)
  }

  submitForm() {
    this.showStatus("Saving...")

    fetch(this.formTarget.action, {
      method: 'PATCH',
      body: new FormData(this.formTarget),
      headers: {
        'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content,
        'Accept': 'text/vnd.turbo-stream.html'
      }
    })
    .then(response => response.text())
    .then(html => {
      Turbo.renderStreamMessage(html)
      this.showStatus("Saved!")
      setTimeout(() => this.hideStatus(), 2000)
    })
    .catch(() => {
      this.showStatus("Error saving", "error")
    })
  }

  showStatus(message, type = "success") {
    this.statusTarget.textContent = message
    this.statusTarget.className = `status status-${type}`
    this.statusTarget.classList.remove("hidden")
  }

  hideStatus() {
    this.statusTarget.classList.add("hidden")
  }
}
<!-- Usage -->
<div data-controller="auto-save" data-auto-save-delay-value="2000">
  <%= form_with model: @task, data: { auto_save_target: "form" } do |form| %>
    <%= form.text_field :title, data: { action: "input->auto-save#save" } %>
    <%= form.text_area :description, data: { action: "input->auto-save#save" } %>
  <% end %>

  <div data-auto-save-target="status" class="hidden"></div>
</div>

2. Keyboard Shortcuts

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

export default class extends Controller {
  connect() {
    this.boundHandleKeydown = this.handleKeydown.bind(this)
    document.addEventListener('keydown', this.boundHandleKeydown)
  }

  disconnect() {
    document.removeEventListener('keydown', this.boundHandleKeydown)
  }

  handleKeydown(event) {
    // Cmd/Ctrl + N for new task
    if ((event.metaKey || event.ctrlKey) && event.key === 'n') {
      event.preventDefault()
      this.focusNewTaskForm()
    }

    // Escape to close modals
    if (event.key === 'Escape') {
      this.closeModals()
    }

    // Cmd/Ctrl + Enter to submit forms
    if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
      this.submitFocusedForm()
    }
  }

  focusNewTaskForm() {
    const titleField = document.querySelector('[data-new-task-target="title"]')
    if (titleField) {
      titleField.focus()
    }
  }

  closeModals() {
    document.querySelectorAll('[data-modal-target="dialog"]').forEach(modal => {
      modal.classList.add('hidden')
    })
  }

  submitFocusedForm() {
    const focusedElement = document.activeElement
    const form = focusedElement.closest('form')
    if (form) {
      form.requestSubmit()
    }
  }
}
<!-- Add to layout -->
<body data-controller="keyboard-shortcuts">
  <!-- Your app content -->
</body>

3. Smooth Transitions

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

export default class extends Controller {
  static targets = ["content"]
  static values = { duration: Number }

  connect() {
    this.durationValue = this.durationValue || 300
  }

  slideUp() {
    const content = this.contentTarget
    const height = content.scrollHeight

    content.style.transition = `max-height ${this.durationValue}ms ease-out`
    content.style.overflow = 'hidden'
    content.style.maxHeight = height + 'px'

    requestAnimationFrame(() => {
      content.style.maxHeight = '0px'
    })

    setTimeout(() => {
      content.classList.add('hidden')
      content.style.removeProperty('transition')
      content.style.removeProperty('overflow')
      content.style.removeProperty('max-height')
    }, this.durationValue)
  }

  slideDown() {
    const content = this.contentTarget

    content.classList.remove('hidden')
    const height = content.scrollHeight

    content.style.transition = `max-height ${this.durationValue}ms ease-out`
    content.style.overflow = 'hidden'
    content.style.maxHeight = '0px'

    requestAnimationFrame(() => {
      content.style.maxHeight = height + 'px'
    })

    setTimeout(() => {
      content.style.removeProperty('transition')
      content.style.removeProperty('overflow')
      content.style.removeProperty('max-height')
    }, this.durationValue)
  }

  toggle() {
    if (this.contentTarget.classList.contains('hidden')) {
      this.slideDown()
    } else {
      this.slideUp()
    }
  }
}

4. Live 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: Number }

  connect() {
    this.delayValue = this.delayValue || 300
  }

  search() {
    clearTimeout(this.timeout)

    const query = this.inputTarget.value.trim()

    if (query.length === 0) {
      this.clearResults()
      return
    }

    this.timeout = setTimeout(() => {
      this.performSearch(query)
    }, this.delayValue)
  }

  performSearch(query) {
    const url = new URL(this.urlValue, window.location.origin)
    url.searchParams.set('q', query)

    fetch(url, {
      headers: {
        'Accept': 'text/html',
        'X-Requested-With': 'XMLHttpRequest'
      }
    })
    .then(response => response.text())
    .then(html => {
      this.resultsTarget.innerHTML = html
    })
    .catch(error => {
      console.error('Search failed:', error)
      this.resultsTarget.innerHTML = '<p>Search failed. Please try again.</p>'
    })
  }

  clearResults() {
    this.resultsTarget.innerHTML = ''
  }
}
<!-- Usage -->
<div data-controller="search" data-search-url-value="<%= search_tasks_path %>">
  <input type="text" 
         placeholder="Search tasks..."
         data-search-target="input"
         data-action="input->search#search">

  <div data-search-target="results"></div>
</div>

Stimulus with Turbo

Stimulus works perfectly with Turbo Frames and Streams:

Re-connecting After Updates

// Stimulus controllers automatically reconnect after Turbo updates
export default class extends Controller {
  connect() {
    console.log("Controller connected (or reconnected after Turbo update)")
    this.setupEventListeners()
  }

  disconnect() {
    console.log("Controller disconnected")
    this.cleanup()
  }
}

Turbo Event Listeners

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

export default class extends Controller {
  connect() {
    document.addEventListener('turbo:before-fetch-request', this.showProgress.bind(this))
    document.addEventListener('turbo:before-fetch-response', this.hideProgress.bind(this))
  }

  showProgress() {
    document.querySelector('#progress-bar').classList.remove('hidden')
  }

  hideProgress() {
    document.querySelector('#progress-bar').classList.add('hidden')
  }
}

Testing Stimulus Controllers

You can test Stimulus controllers in isolation:

// test/javascript/controllers/collapse_controller.test.js
import { Application } from "@hotwired/stimulus"
import CollapseController from "../../app/javascript/controllers/collapse_controller"

describe("CollapseController", () => {
  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="collapse">
        <button data-collapse-target="button" 
                data-action="click->collapse#toggle">
          Expand
        </button>
        <div data-collapse-target="content" class="hidden">
          Content
        </div>
      </div>
    `

    const application = Application.start()
    application.register("collapse", CollapseController)
  })

  it("toggles content visibility", () => {
    const button = document.querySelector('[data-collapse-target="button"]')
    const content = document.querySelector('[data-collapse-target="content"]')

    expect(content.classList.contains('hidden')).toBe(true)

    button.click()

    expect(content.classList.contains('hidden')).toBe(false)
    expect(button.textContent).toBe('Collapse')
  })
})

Best Practices

1. Keep Controllers Small and Focused

// Good - single responsibility
class DropdownController extends Controller {
  toggle() { /* dropdown logic */ }
}

// Avoid - too many responsibilities
class UIController extends Controller {
  toggleDropdown() { /* ... */ }
  validateForm() { /* ... */ }
  animateModal() { /* ... */ }
}

2. Use Values for Configuration

// Good - configurable
static values = { delay: Number, message: String }

// Avoid - hardcoded values
const DELAY = 5000

3. Clean Up Event Listeners

connect() {
  this.boundResize = this.handleResize.bind(this)
  window.addEventListener('resize', this.boundResize)
}

disconnect() {
  window.removeEventListener('resize', this.boundResize)
}

4. Use Lifecycle Callbacks

connect() {
  // Setup when controller connects to DOM
}

disconnect() {
  // Cleanup when controller disconnects
}

targetConnected(target, name) {
  // When a target connects
}

valueChanged(value, previousValue) {
  // When a value changes
}

Common Use Cases

Perfect for Stimulus:
- Form enhancements (auto-save, validation, character counters)
- UI interactions (dropdowns, modals, tabs)
- Progressive enhancements (keyboard shortcuts, animations)
- Simple widgets (image carousels, accordions)

Not ideal for Stimulus:
- Complex state management
- Heavy data processing
- Large single-page applications
- Complex routing

What I Learned

Using Stimulus taught me:

  1. JavaScript can be simple - No complex build tools or state management
  2. HTML drives behavior - Markup declares what JavaScript should do
  3. Progressive enhancement works - Features work without JavaScript, better with it
  4. Small controllers are better - Focus on single responsibilities
  5. Turbo integration is seamless - Controllers automatically reconnect after updates

Next Steps

With Stimulus in your toolkit, you can:
- Add smooth animations to Turbo Frame updates
- Create keyboard shortcuts for power users
- Build rich form interactions
- Add client-side validations
- Create reusable UI components

In my next post, I'll show you how to take your Rails and Hotwire skills mobile with Hotwire Native for iOS.

Key takeaways:
- Stimulus adds JavaScript behavior without complexity
- HTML data attributes drive controller behavior
- Controllers are small, focused classes
- Perfect for progressive enhancement
- Works seamlessly with Turbo Frames and Streams

Try adding a Stimulus controller to your app! Start with something simple like a collapsible section or auto-save form. You'll be surprised how much interactivity you can add with so little code.


Next up: "My First Unity Game: Lessons from a Rails Developer" - We'll explore how object-oriented thinking translates between web development and game development.

Christopher Lim

Christopher Lim

Rails developer and Unity explorer. Family man, lifelong learner, and builder turning ideas into polished applications. Passionate about quality software development and continuous improvement.

Back to All Posts
Reading time: 8 min read

Enjoyed this rails development post?

Follow me for more insights on rails development, Rails development, and software engineering excellence.