Stimulus Controllers Reference
This document provides a comprehensive reference for all Stimulus controllers in the Heatwave project.
Quick Reference
Section titled “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
Section titled “WWW Controllers”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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Utility Controllers”turnstile
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Best Practices”Base Controller
Section titled “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
Section titled “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 navigationif (this.element.dataset.myControllerConnected === 'true') returnthis.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 controllersevent.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 debouncethis.lastActionTime = nowreturn 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.currentTargetif (this.isProcessing || button.disabled) returnthis.isProcessing = truebutton.disabled = truetry {await this.executeAction()} finally {this.isProcessing = falsebutton.disabled = false}} -
Skip During Preview: For expensive operations (API calls, animations)
// Using ApplicationControllerif (this.isPreview) return// Or manuallyif (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
Section titled “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)
Section titled “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 removeconnect() { 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 propertyconnect() { 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
Section titled “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
Section titled “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)
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Advanced Patterns”Outlets API (Cross-Controller Communication)
Section titled “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>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)
Section titled “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)
Section titled “Dispatch Events (Loose Coupling)”Use this.dispatch() for events other controllers can listen to:
this.dispatch("changed", { detail: { value: newValue } })
// In HTML (parent listens)<div data-controller="cart" data-action="quantity:changed->cart#updateTotal">Template Tags (Dynamic HTML)
Section titled “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
Section titled “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
Section titled “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