// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
connect() {
console.log("Dropdown connected")
}
toggle(event) {
event.preventDefault()
this.menuTarget.classList.toggle("hidden")
}
hide(event) {
// Hide if clicking outside dropdown
if (!this.element.contains(event.target)) {
this.menuTarget.classList.add("hidden")
}
}
disconnect() {
// Cleanup when element is removed
}
}
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
open() {
this.element.classList.remove("hidden")
document.body.classList.add("overflow-hidden")
}
close() {
this.element.classList.add("hidden")
document.body.classList.remove("overflow-hidden")
}
closeOnEscape(event) {
if (event.key === "Escape") {
this.close()
}
}
closeBackground(event) {
if (event.target === this.element) {
this.close()
}
}
}
// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit"]
validate() {
const form = this.element
const isValid = form.checkValidity()
this.submitTarget.disabled = !isValid
}
submit(event) {
event.preventDefault()
const formData = new FormData(this.element)
fetch(this.element.action, {
method: this.element.method,
body: formData,
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
}
})
.then(response => response.json())
.then(data => {
console.log('Success:', data)
})
}
}
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "button"]
static values = {
successMessage: String,
successDuration: { type: Number, default: 2000 }
}
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.showSuccess()
}
showSuccess() {
const originalText = this.buttonTarget.innerText
this.buttonTarget.innerText = this.successMessageValue || "Copied!"
setTimeout(() => {
this.buttonTarget.innerText = originalText
}, this.successDurationValue)
}
}
<!-- Dropdown -->
<div data-controller="dropdown"
data-action="click@window->dropdown#hide">
<button data-action="dropdown#toggle">
Menu
</button>
<div data-dropdown-target="menu" class="hidden">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
<a href="/logout">Logout</a>
</div>
</div>
<!-- Modal -->
<div data-controller="modal" class="hidden"
data-action="keyup@window->modal#closeOnEscape click@window->modal#closeBackground">
<div data-modal-target="content">
<h2>Modal Title</h2>
<p>Modal content here</p>
<button data-action="modal#close">Close</button>
</div>
</div>
<button data-action="modal#open">Open Modal</button>
<!-- Form validation -->
<%= form_with model: @post, data: {
controller: "form",
action: "input->form#validate submit->form#submit"
} do |f| %>
<%= f.text_field :title, required: true %>
<%= f.text_area :content, required: true %>
<%= f.submit "Create Post", data: { form_target: "submit" } %>
<% end %>
<!-- Clipboard copy -->
<div data-controller="clipboard"
data-clipboard-success-message-value="Copied!"
data-clipboard-success-duration-value="3000">
<input data-clipboard-target="source"
value="Text to copy"
readonly>
<button data-clipboard-target="button"
data-action="clipboard#copy">
Copy
</button>
</div>
<!-- Autocomplete -->
<div data-controller="autocomplete">
<input type="text"
data-autocomplete-target="input"
data-action="input->autocomplete#search">
<ul data-autocomplete-target="results" class="hidden"></ul>
</div>
<!-- Character counter -->
<div data-controller="counter">
<textarea data-counter-target="input"
data-action="input->counter#count"
maxlength="280"></textarea>
<span data-counter-target="display">0 / 280</span>
</div>
<!-- Toggle visibility -->
<div data-controller="toggle">
<button data-action="toggle#toggle">
Show Details
</button>
<div data-toggle-target="content" class="hidden">
<p>Hidden details content</p>
</div>
</div>
<!-- Multiple controllers on same element -->
<div data-controller="dropdown modal tooltip"
data-action="mouseenter->tooltip#show mouseleave->tooltip#hide">
<!-- Element has 3 controllers -->
</div>
<!-- Values (passing data to controller) -->
<div data-controller="slideshow"
data-slideshow-index-value="0"
data-slideshow-auto-play-value="true">
<!-- Controller can access this.indexValue and this.autoPlayValue -->
</div>
// Autocomplete controller
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
search() {
const query = this.inputTarget.value
if (query.length < 2) {
this.resultsTarget.classList.add("hidden")
return
}
fetch(`${this.urlValue}?q=${query}`)
.then(response => response.json())
.then(data => {
this.displayResults(data)
})
}
displayResults(results) {
this.resultsTarget.innerHTML = results
.map(result => `
<li data-action="click->autocomplete#select" data-value="${result.id}">
${result.name}
</li>
`)
.join('')
this.resultsTarget.classList.remove("hidden")
}
select(event) {
const value = event.currentTarget.dataset.value
this.inputTarget.value = event.currentTarget.textContent
this.resultsTarget.classList.add("hidden")
}
}
// Debouncing with Stimulus values
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
initialize() {
this.search = this.debounce(this.search.bind(this), this.delayValue)
}
search() {
// Debounced search logic
}
debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
}
// Composing controllers
import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from "stimulus-use"
export default class extends Controller {
connect() {
useClickOutside(this)
}
clickOutside(event) {
this.element.classList.add("hidden")
}
}
Stimulus adds JavaScript behavior to HTML without building SPAs. Controllers attach to DOM elements via data-controller. I use Stimulus for modals, dropdowns, form validation, autocomplete. Actions connect events to controller methods via data-action. Targets reference DOM elements by name via data-target. Values pass data from HTML to JavaScript. Stimulus follows progressive enhancement—HTML works first, JavaScript enhances. Controllers are reusable—same dropdown controller on all dropdowns. Stimulus integrates perfectly with Turbo for reactive UIs. Understanding Stimulus lifecycle—connect, disconnect—enables proper setup/teardown. Stimulus keeps JavaScript organized in small, focused controllers. It's the missing link between Rails and modern JavaScript.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.