Skip to content

Stimulus Controllers Reference

This document provides a comprehensive reference for all Stimulus controllers in the Heatwave project.

ControllerPurposePrimary Usage
cartAdd to cart with analyticsProduct pages (legacy, prefer Turbo Stream)
quantityIncrement/decrement inputsCart, product pages
product-pageDynamic product data loadingProduct detail pages
product-filterClient-side filtering/sortingProduct grids
searchUnified search with SwiftypeNavbar, modals
lead-formForm validation + floating labelsLead capture forms
turnstileCloudflare captcha (lazy)Contact forms
tooltipBootstrap tooltips (custom options)Throughout site
popoverBootstrap popoversInfo popovers
toastAuto-dismiss Bootstrap toastsFlash messages
clipboardCopy to clipboardBrand assets, CRM
lazy-frameLazy load on collapse showUppy uploaders

Note: Bootstrap dropdowns and collapses use the native data-api (data-bs-toggle), not Stimulus controllers. See Bootstrap Data-API vs Custom 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.erb
  • app/views/www/products/_out_of_stock.html.erb
  • app/views/my_carts/_cart.html.erb

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.erb
  • app/views/my_carts/_line_item.html.erb

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.erb
  • app/views/www/products/_service_body.html.erb

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.erb
  • app/views/pages/towel-warmer.html.erb

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.erb
  • app/views/www/shared/_search_modal.html.erb

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.erb
  • app/views/www/contact/_side_panel.html.erb

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.erb
  • app/views/www/posts/show.html.erb

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

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.erb
  • app/views/pages/*.html.erb

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 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

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.erb
  • app/views/www/contact/_form.html.erb

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.


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_copy helper throughout CRM views

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

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

All controllers are designed to work with Turbo Drive. Key patterns:

  1. ❌ AVOID Connection Guards with dataset.*Connected

    Don’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.

  2. 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
    }
  3. 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
    }
  4. 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
    }
    }
  5. 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
  6. Cleanup on Disconnect: Always clean up listeners and third-party instances

    disconnect() {
    if (this.flatpickrInstance) this.flatpickrInstance.destroy()
    }

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
}

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.js
  • tom_select_controller.js
  • offcanvas_reset_controller.js
  • lazy_modal_controller.js

stimulus-use provides specific high-level behaviors, NOT generic event listeners.

✅ Use stimulus-use for:

  • useClickOutside - Detect clicks outside element
  • useIntersection - Visibility/scroll detection (lazy loading)
  • useDebounce - High-frequency event throttling
  • useHotkeys - Keyboard shortcuts
  • useVisibility - Tab visibility changes

❌ stimulus-use does NOT have:

  • useEventListener - Use data-action or this.observe() instead
  • usePreview - Use this.isPreview from 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:

  1. 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>
  2. 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() }
    })
    })
  3. 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)

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 options
  • popover_controller.js - Bootstrap popovers
  • www_home_controller.js - Home page popovers
  • landing_page_controller.js - Landing page popovers

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.locale first (set by Rails)
  • Falls back to URL path extraction (/en-US/...)
  • Reports to AppSignal if unavailable
  • Returns null so you can handle gracefully

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-with handles 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"))
)
}

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>
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
}
}

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)
}
}

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">

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)
}

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 = ""
}
}

  • 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 with isPreview and observe()
  • app/javascript/utils/use_turbo_cache.js - Turbo Drive cache cleanup composable for Bootstrap components