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:
- JavaScript can be simple - No complex build tools or state management
- HTML drives behavior - Markup declares what JavaScript should do
- Progressive enhancement works - Features work without JavaScript, better with it
- Small controllers are better - Focus on single responsibilities
- 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.