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.erb
  • app/views/www/products/_out_of_stock.html.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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.erb
  • app/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_copy helper 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:

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

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

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)

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

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

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

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