Stimulus Controllers Reference
This document provides a comprehensive reference for all Stimulus controllers in the Heatwave project.
Quick Reference
| Controller | Purpose | Primary Usage |
|---|---|---|
cart |
Add to cart with analytics | Product pages (legacy, prefer Turbo Stream) |
quantity |
Increment/decrement inputs | Cart, product pages |
product-page |
Dynamic product data loading | Product detail pages |
product-filter |
Client-side filtering/sorting | Product grids |
search |
Unified search with Swiftype | Navbar, modals |
lead-form |
Form validation + floating labels | Lead capture forms |
turnstile |
Cloudflare captcha (lazy) | Contact forms |
tooltip |
Bootstrap tooltips (custom options) | Throughout site |
popover |
Bootstrap popovers | Info popovers |
toast |
Auto-dismiss Bootstrap toasts | Flash messages |
clipboard |
Copy to clipboard | Brand assets, CRM |
lazy-frame |
Lazy load on collapse show | Uppy uploaders |
Note: Bootstrap dropdowns and collapses use the native data-api (
data-bs-toggle), not Stimulus controllers. See Bootstrap Data-API vs Custom Controllers.
WWW Controllers
cart
Handles add-to-cart functionality with toast feedback and analytics tracking.
<button data-controller="cart"
data-action="click->cart#add"
data-cart-sku-value="TRT-120-3X5"
data-cart-name-value="TempZone Floor Heating Kit"
data-cart-price-value="299.00">
Add to Cart
</button>
Used in:
app/views/www/products/_add_to_cart.html.erbapp/views/www/products/_out_of_stock.html.erbapp/views/my_carts/_cart.html.erb
quantity
Handles quantity increment/decrement with min/max limits.
<div data-controller="quantity" data-quantity-max-value="10">
<button data-action="click->quantity#decrement" data-quantity-target="decrement">-</button>
<input type="number" data-quantity-target="input" value="1">
<button data-action="click->quantity#increment" data-quantity-target="increment">+</button>
</div>
Used in:
app/views/www/products/_add_to_cart.html.erbapp/views/my_carts/_line_item.html.erb
product-page
Loads dynamic product data (price, stock, CTA) from consolidated JSON endpoint.
<main data-controller="product-page"
data-product-page-sku-value="TRT-120-3X5"
data-product-page-data-url-value="/en-US/products/code/TRT-120-3X5/product_data">
<div id="price-info"><!-- Price loaded here --></div>
<div id="stock-status"><!-- Stock loaded here --></div>
<div id="main-call-to-action"><!-- CTA loaded here --></div>
</main>
Used in:
app/views/www/products/_sku_body.html.erbapp/views/www/products/_service_body.html.erb
product-filter
Client-side filtering and sorting of product grids.
<div data-controller="product-filter">
<!-- Filter checkboxes -->
<div data-product-filter-target="filter" data-group="type">
<input type="checkbox" value=".cable" id="type-cable">
<label for="type-cable">Cable</label>
</div>
<!-- Product cards -->
<div data-product-filter-target="item" data-type="cable" data-price="299">
<!-- Card content -->
</div>
</div>
Used in:
app/views/www/showcases/index.html.erbapp/views/pages/towel-warmer.html.erb
search
Unified search combining Swiftype and Publications.
<form data-controller="search" data-action="submit->search#submit">
<input type="search" data-search-target="input" placeholder="Search...">
</form>
<div id="searchModal" class="modal">
<div data-search-target="filters">
<button data-action="click->search#filter">All</button>
<button data-action="click->search#filter">Products</button>
</div>
<div data-search-target="results"></div>
</div>
Used in:
app/views/www/navbar/_search_form.html.erbapp/views/www/shared/_search_modal.html.erb
lead-form
Form validation and floating labels for lead capture.
<form data-controller="lead-form" data-action="submit->lead-form#onSubmit">
<div class="form-floating">
<input type="text" name="lead[name]"
data-lead-form-target="name floatingField"
data-action="blur->lead-form#validateOnBlur">
<label>Name</label>
</div>
<button type="submit">Send</button>
</form>
Used in:
app/views/www/leads/_lead_form.html.erbapp/views/www/contact/_side_panel.html.erb
social-share
Social sharing with native Web Share API and popup windows.
<div data-controller="social-share"
data-social-share-url-value="https://www.warmlyyours.com/blog/my-post"
data-social-share-title-value="My Blog Post">
<button data-social-share-target="nativeButton"
data-action="click->social-share#nativeShare" class="d-none">
Share
</button>
<button data-action="click->social-share#copyLink">Copy Link</button>
<a href="https://linkedin.com/shareArticle?url=..."
data-action="click->social-share#openPopup">LinkedIn</a>
</div>
Used in:
app/views/components/www/social_share_component.html.erbapp/views/www/posts/show.html.erb
reviews-io-widget
Reviews.io product review widget with lazy loading.
<div data-controller="reviews-io-widget"
data-reviews-io-widget-config-value='{
"store": "warmlyyours.com",
"sku": "TRT-120-3X5"
}'
id="reviewsio-product-widget">
</div>
Used in:
app/views/www/products/_reviews_tab.html.erb
landing-page
Google Maps, TOC sidebar, and popovers for landing pages.
<main data-controller="landing-page"
data-landing-page-markers-value='[{"lat":42.0,"lng":-87.5,"title":"Chicago"}]'>
<div id="showroom-map"></div>
<aside id="table-of-contents">
<button data-landing-page-target="tocExpand"
data-action="click->landing-page#toggleTOC">
<i class="fa fa-bars"></i>
</button>
</aside>
</main>
Used in:
app/views/www/landing_pages/*.html.erbapp/views/pages/*.html.erb
www-home
Home page popovers for logo carousel.
<main data-controller="www-home">
<div data-www-home-target="carousel" class="logo-carousel">
<button class="carousel-logo" data-bs-toggle="popover">
<img src="/logos/this-old-house.png">
</button>
</div>
</main>
Used in:
app/views/www/home/index.html.erb
smart-services
Smart Services appointment booking flow.
<div data-controller="smart-services">
<input data-smart-services-target="zipInput"
data-action="keyup->smart-services#checkZip">
<div data-smart-services-target="appointmentCalendar"
id="appointment_calendar"></div>
<button data-action="click->smart-services#scheduleService">Book</button>
</div>
Used in:
app/views/www/smart_services/new.html.erb
Utility Controllers
turnstile
Cloudflare Turnstile captcha with lazy loading.
<div data-controller="turnstile"
data-turnstile-sitekey-value="0x4AAAAAAA..."
data-turnstile-theme-value="light"
data-action="turnstile:success->form#onCaptcha">
</div>
Used in:
app/views/www/leads/_lead_form.html.erbapp/views/www/contact/_form.html.erb
tooltip
Bootstrap tooltips initialization.
<div data-controller="tooltip">
<button data-bs-toggle="tooltip" title="Edit item">
<i class="fa fa-edit"></i>
</button>
</div>
Used in: Throughout site where tooltips are needed.
clipboard
Copy to clipboard with rich text support.
<button data-controller="clipboard"
data-clipboard-text-value="https://www.warmlyyours.com"
data-action="click->clipboard#copy"
data-clipboard-success-content-value="Copied!">
Copy Link
</button>
Used in:
app/views/pages/brand-assets.html.erb- Via
clipboard_copyhelper throughout CRM views
lazy-frame
Lazy load content on Bootstrap collapse show.
<button data-bs-toggle="collapse" data-bs-target="#upload-section">
Add Photo
</button>
<div class="collapse" id="upload-section"
data-controller="lazy-frame"
data-lazy-frame-url-value="/uploads/lazy_uppy">
<div data-lazy-frame-target="placeholder">Loading...</div>
<div data-lazy-frame-target="content"></div>
</div>
Used in:
app/views/www/leads/_lead_form.html.erb
Best Practices
Base Controller
Extend ApplicationController for common utilities:
import ApplicationController from "./application_controller"
export default class extends ApplicationController {
connect() {
if (this.isPreview) return // Skip during Turbo preview
// Use observe() for events that can't go in data-action
this.observe(window, 'thirdPartyEvent', this.handleEvent)
}
}
ApplicationController provides:
this.isPreview- Check if in Turbo preview (cached restoration)this.observe(element, event, handler)- Auto-cleanup event listeners
Turbo Compatibility
All controllers are designed to work with Turbo Drive. Key patterns:
-
❌ AVOID Connection Guards with
dataset.*ConnectedDon't do this - it breaks Turbo cache restoration:
// BAD: Prevents controller from working after back navigation if (this.element.dataset.myControllerConnected === 'true') return this.element.dataset.myControllerConnected = 'true'Stimulus safely handles controller instances. The connection guard pattern interferes with Turbo's cache restoration because the flag persists in the cached DOM but the controller instance is new.
-
Stop Event Propagation for Actions
Prevent double-firing from event bubbling:
increment(event) { event.stopPropagation() // Stop bubbling to parent controllers event.preventDefault() // Prevent default browser behavior // ... action logic } -
Debounce Rapid Actions
Prevent rapid clicks from double-firing:
connect() { this.lastActionTime = 0 } canExecuteAction() { const now = Date.now() if (now - this.lastActionTime < 100) return false // 100ms debounce this.lastActionTime = now return true } increment(event) { event.stopPropagation() if (!this.canExecuteAction()) return // ... action logic } -
Lock UI During Async Operations
async add(event) { event.stopPropagation() event.preventDefault() const button = event.currentTarget if (this.isProcessing || button.disabled) return this.isProcessing = true button.disabled = true try { await this.executeAction() } finally { this.isProcessing = false button.disabled = false } } -
Skip During Preview: For expensive operations (API calls, animations)
// Using ApplicationController if (this.isPreview) return // Or manually if (document.documentElement.hasAttribute("data-turbo-preview")) return -
Cleanup on Disconnect: Always clean up listeners and third-party instances
disconnect() { if (this.flatpickrInstance) this.flatpickrInstance.destroy() }
Event Handling
Always prefer data-action over manual addEventListener:
<!-- Good: Element events -->
<button data-action="click->my-controller#doSomething">Click</button>
<!-- Good: Window/document events -->
<div data-action="resize@window->my-controller#onResize">
<div data-action="keydown@document->my-controller#onKeydown">
<!-- Avoid: Manual listeners require cleanup -->
this.element.addEventListener('click', this.doSomething)
For events that can't use data-action (third-party libraries, custom events):
Extend ApplicationController and use this.observe():
import ApplicationController from "./application_controller"
export default class extends ApplicationController {
connect() {
// Third-party library events
this.observe(this.flatpickr, 'change', this.onDateChange)
// Custom events from external scripts
this.observe(window, 'reviews:loaded', this.onReviewsLoaded)
}
// No manual cleanup needed - observe() handles it
}
Event Listener Cleanup with .bind(this)
Critical: When using .bind(this) with addEventListener, you MUST store the bound function to properly remove it later.
// ❌ BAD: Creates new function each time, can't remove
connect() {
this.element.addEventListener('change', this.handleChange.bind(this))
}
disconnect() {
// This creates a DIFFERENT function, listener is NOT removed!
this.element.removeEventListener('change', this.handleChange.bind(this))
}
// ✅ GOOD: Store bound handler as instance property
connect() {
this.boundHandleChange = this.handleChange.bind(this)
this.element.addEventListener('change', this.boundHandleChange)
}
disconnect() {
if (this.boundHandleChange) {
this.element.removeEventListener('change', this.boundHandleChange)
}
}
Controllers that follow this pattern:
tab_persist_controller.jstom_select_controller.jsoffcanvas_reset_controller.jslazy_modal_controller.js
Using stimulus-use
stimulus-use provides specific high-level behaviors, NOT generic event listeners.
✅ Use stimulus-use for:
useClickOutside- Detect clicks outside elementuseIntersection- Visibility/scroll detection (lazy loading)useDebounce- High-frequency event throttlinguseHotkeys- Keyboard shortcutsuseVisibility- Tab visibility changes
❌ stimulus-use does NOT have:
useEventListener- Usedata-actionorthis.observe()insteadusePreview- Usethis.isPreviewfrom ApplicationController
import { Controller } from "@hotwired/stimulus"
import { useClickOutside, useDebounce } from "stimulus-use"
export default class extends Controller {
static debounces = ['search'] // Debounce search method
connect() {
useClickOutside(this) // Calls clickOutside() when clicking outside
useDebounce(this) // Debounces methods listed in static debounces
}
clickOutside(event) {
this.close()
}
search() {
// This is debounced automatically
}
}
Bootstrap Data-API vs Custom Stimulus Controllers
Key Learning: Bootstrap 5's data-api uses event delegation on document and automatically initializes components via data-bs-toggle attributes. For standard Bootstrap components (dropdowns, collapses), prefer the native data-api over custom Stimulus controllers.
Why?
- Bootstrap's
getOrCreateInstance()expects instances to persist across interactions - Custom Stimulus controllers can interfere with this by disposing instances prematurely
- The data-api already handles all standard use cases
Recommended approach:
-
Use native Bootstrap data-api for dropdowns and collapses:
<!-- Just use data-bs-toggle, no Stimulus controller needed --> <button data-bs-toggle="dropdown">Menu</button> <div class="dropdown-menu">...</div> -
Global Turbo cleanup in
client/js/www/setup/turbo.js:document.addEventListener('turbo:before-cache', () => { // Reset dropdown DOM state (DON'T dispose - data-api handles it) document.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(el => { el.classList.remove('show') el.setAttribute('aria-expanded', 'false') }) document.querySelectorAll('.dropdown-menu.show').forEach(menu => { menu.classList.remove('show') menu.removeAttribute('style') menu.removeAttribute('data-popper-placement') }) // Dispose tooltips/popovers (these ARE created programmatically) document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { const instance = window.bootstrap?.Tooltip.getInstance(el) if (instance) { instance.hide(); instance.dispose() } }) }) -
Use Stimulus controllers only for:
- Custom initialization options (e.g., tooltips with
html: true) - Complex interactions not covered by data-api
- Programmatic control (show/hide from JavaScript)
- Custom initialization options (e.g., tooltips with
Using useTurboCache (Tooltips, Popovers, Custom Components)
For components that require programmatic initialization (like tooltips with custom options), use the useTurboCache composable:
import { Controller } from "@hotwired/stimulus"
import { useTurboCache } from "../utils/use_turbo_cache"
export default class extends Controller {
connect() {
this.initializeTooltips()
useTurboCache(this, { cleanup: this.cleanupTooltips.bind(this) })
}
initializeTooltips() {
if (!window.bootstrap?.Tooltip) return
this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new window.bootstrap.Tooltip(el, { html: true, sanitize: false })
})
}
cleanupTooltips() {
if (!window.bootstrap?.Tooltip) return
this.element.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
const instance = window.bootstrap.Tooltip.getInstance(el)
if (instance) { instance.hide(); instance.dispose() }
})
}
}
Controllers using this pattern:
tooltip_controller.js- Custom tooltip optionspopover_controller.js- Bootstrap popoverswww_home_controller.js- Home page popoverslanding_page_controller.js- Landing page popovers
Locale Handling
NEVER default locale to a static value. Use the locale utility:
import { getLocaleOrDisconnect, getCurrencyForLocale, buildLocalePath } from "../utils/locale"
connect() {
// For critical controllers - disconnects if locale unavailable
this.locale = getLocaleOrDisconnect(this, { controllerName: 'my-controller' })
if (!this.locale) return
this.currency = getCurrencyForLocale(this.locale)
this.apiUrl = buildLocalePath('/api/endpoint.json', this.locale)
}
For non-critical operations:
import { getLocale } from "../utils/locale"
someMethod() {
const locale = getLocale()
if (!locale) {
debug.warn('my-controller', 'Cannot proceed - locale unavailable')
return
}
// proceed with locale
}
Why? Silently defaulting to en-US when en-CA is expected causes:
- Wrong currency (USD vs CAD)
- Wrong product catalog/pricing
- Silent data corruption
The locale utility:
- Tries
window.localefirst (set by Rails) - Falls back to URL path extraction (
/en-US/...) - Reports to AppSignal if unavailable
- Returns
nullso you can handle gracefully
Debugging
Use the debug utility instead of console.log:
import { debug } from "../utils/debug"
debug('my-controller', 'Connected', { sku: this.skuValue })
debug.warn('my-controller', 'Something unexpected')
Debug logs only appear in development (window.env === 'development').
Turbo Stream Forms vs Stimulus Controllers
Key Learning: For form submissions that need to update multiple parts of the page (cart count, toast, analytics), prefer Turbo Stream responses over complex Stimulus controllers.
Example: Add to Cart
Instead of a complex cart_controller.js that:
- Handles fetch requests
- Manages button states
- Dispatches analytics events
- Shows toasts
- Updates cart counts
Use a simple form with Turbo Stream:
<%# Form submits via Turbo Stream %>
<%= form_with url: add_item_my_cart_path,
method: :post,
data: { controller: "quantity" } do |f| %>
<%= hidden_field_tag :sku, product.sku %>
<button type="submit" data-turbo-submits-with="Adding...">
Add to Cart
</button>
<% end %>
<%# app/views/my_carts/add_item.turbo_stream.erb %>
<%= turbo_stream.update "cart-count", @cart.line_items.parents_only.size.to_s %>
<%= turbo_stream.toast do %>
<div class="toast show" data-controller="toast">
Added <%= @product_info[:name] %> to cart!
</div>
<% end %>
<%= turbo_stream.analytics_event "Cart - Product Added", {
sku: @product_info[:sku],
price: @product_info[:price]
} %>
Benefits:
- Server handles all logic (analytics, cart updates)
data-turbo-submits-withhandles button loading state automatically- Custom stream actions (
toast,analytics_event) keep templates clean - No complex JavaScript state management
Custom Turbo Stream actions are defined in app/javascript/turbo_stream_actions.js:
import { StreamActions } from "@hotwired/turbo"
StreamActions.toast = function() {
document.getElementById("toast-container")
.appendChild(this.templateContent.cloneNode(true))
}
StreamActions.analytics_event = function() {
window.Analytics.track(
this.getAttribute("event-name"),
JSON.parse(this.getAttribute("properties"))
)
}
Advanced Patterns
Outlets API (Cross-Controller Communication)
Use Outlets instead of getControllerForElementAndIdentifier.
Outlets provide type-safe, reactive communication between controllers:
<div data-controller="search"
data-search-list-outlet="#results-list">
<input type="text" data-action="input->search#query">
</div>
<ul id="results-list" data-controller="list">...</ul>
// search_controller.js
export default class extends Controller {
static outlets = ["list"]
query(event) {
if (!this.hasListOutlet) return // Safety check
this.listOutlet.filter(event.target.value) // Direct method call
}
}
Value Change Callbacks (Reactive State)
Never manually call render(). Change a value and let the callback handle it.
export default class extends Controller {
static values = { page: { type: Number, default: 1 } }
// BAD: Imperative
nextPage() {
this.pageValue++
this.loadData() // Don't call manually
this.updateUI() // Don't call manually
}
// GOOD: Reactive
nextPage() {
this.pageValue++ // Just update state
}
pageValueChanged(newPage, oldPage) {
if (oldPage === undefined) return // Skip initial
this.loadData(newPage)
this.updateUI(newPage)
}
}
Dispatch Events (Loose Coupling)
Use this.dispatch() for events other controllers can listen to:
// quantity_controller.js
this.dispatch("changed", { detail: { value: newValue } })
// In HTML (parent listens)
<div data-controller="cart"
data-action="quantity:changed->cart#updateTotal">
Template Tags (Dynamic HTML)
Avoid building HTML strings. Use <template> tags:
<div data-controller="nested-form">
<div data-nested-form-target="items"></div>
<template data-nested-form-target="template">
<div class="row">
<input type="text" name="items[NEW_RECORD][name]">
</div>
</template>
<button data-action="nested-form#add">Add Item</button>
</div>
add(e) {
e.preventDefault()
const content = this.templateTarget.content.cloneNode(true)
// Replace NEW_RECORD with timestamp for uniqueness
const html = content.querySelector('.row')
html.innerHTML = html.innerHTML.replace(/NEW_RECORD/g, Date.now())
this.itemsTarget.appendChild(content)
}
Idempotent 3rd Party Libraries
Turbo caches pages. Hitting "Back" restores from cache and calls connect() again.
export default class extends Controller {
connect() {
// Guard: Don't reinitialize if already exists
if (this.map) return
this.map = new GoogleMap(this.element, { ... })
}
disconnect() {
// CRITICAL: Clean up for cache
if (this.map) {
this.map.remove()
this.map = null
}
// Clear injected DOM if needed
this.element.innerHTML = ""
}
}
See Also
- Stimulus Handbook - Official documentation
- Stimulus Reference: Outlets - Cross-controller communication
- stimulus-use - High-level behaviors (clickOutside, intersection, debounce)
- Bootstrap 5.3 JavaScript - Modal, Tooltip, Popover, etc.
app/javascript/controllers/application_controller.js- Base class withisPreviewandobserve()app/javascript/utils/use_turbo_cache.js- Turbo Drive cache cleanup composable for Bootstrap components