All skills
smnandre avatar

/stimulus

@6d7ef8e

Stimulus JS framework for Symfony UX -- client-side behavior via HTML data attributes, zero server round-trips. Use when creating controllers for DOM manipulation, handling click/input/submit events, managing targets and values, wiring outlets between controllers, wrapping third-party JS libraries, or building toggles, dropdowns, modals, tabs, clipboard interactions. Code triggers: data-controller, data-action, data-target, data-*-value, data-*-class, data-*-outlet, stimulusFetch lazy, connect(), disconnect(), static targets, static values. Also trigger when the user asks "how do I add a click handler", "how to toggle a class", "how to build a dropdown/modal/tabs", "how to wrap a JS library in Symfony", "add keyboard shortcuts", "lazy-load a controller", "listen to global events", "communicate between controllers". Do NOT trigger for partial page updates without JS (use turbo), server-rendered reactivity (use live-component), or reusable Twig templates (use twig-component).

Use this Skill: https://skilld.dev/gh/smnandre/symfony-ux-skills/stimulus

This session only. Nothing lands on disk.

referencesgotchas.md

≈2.2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Stimulus Gotchas & Debugging

Common mistakes, pitfalls, and debugging strategies.


Naming Mistakes

Controller Name Mismatch

// File: assets/controllers/userProfile_controller.js  ❌ Wrong
// File: assets/controllers/user_profile_controller.js ✅ Correct
<!-- data-controller must match file name (underscores become dashes) -->
<div data-controller="userProfile">  ❌ Wrong
<div data-controller="user-profile"> ✅ Correct

Target Name Case

static targets = ['errorMessage']  // camelCase in JS
<!-- camelCase preserved in HTML -->
<span data-search-target="errorMessage">   ✅ Correct
<span data-search-target="error-message">  ❌ Wrong

Value Name Case

static values = { apiUrl: String }  // camelCase in JS
<!-- kebab-case in HTML -->
<div data-search-api-url-value="/api">  ✅ Correct
<div data-search-apiUrl-value="/api">   ❌ Wrong

Scope Issues

Target Outside Controller Scope

<div data-controller="tabs">
    <button data-tabs-target="tab">Tab 1</button>  ✅ Inside scope
</div>
<div data-tabs-target="panel">Content</div>  ❌ Outside scope - won't work

Nested Controller Confusion

<div data-controller="parent">
    <div data-controller="child">
        <span data-parent-target="item">  ❌ Child blocks parent's access
        <span data-child-target="item">   ✅ Correct scope
    </div>
</div>

Parent cannot see targets inside nested child controller scope.


Lifecycle Timing

Accessing Targets Too Early

export default class extends Controller {
    static targets = ['output'];

    initialize() {
        this.outputTarget.textContent = 'Hello';  ❌ Targets not ready
    }

    connect() {
        this.outputTarget.textContent = 'Hello';  ✅ Targets available
    }
}

Not Cleaning Up

export default class extends Controller {
    connect() {
        this.interval = setInterval(() => this.tick(), 1000);
        window.addEventListener('resize', this.handleResize);  ❌ Leaks
    }

    // Missing disconnect() - causes memory leaks with Turbo!

    disconnect() {
        clearInterval(this.interval);
        window.removeEventListener('resize', this.handleResize);  ✅
    }
}

Binding this for Event Listeners

export default class extends Controller {
    connect() {
        // ❌ Wrong - `this` will be wrong when called
        window.addEventListener('resize', this.handleResize);
        
        // ✅ Correct - bind or use arrow function
        this.boundResize = this.handleResize.bind(this);
        window.addEventListener('resize', this.boundResize);
    }

    disconnect() {
        window.removeEventListener('resize', this.boundResize);
    }
}

Value Gotchas

Values Are Strings in HTML

<div data-counter-count-value="5">  <!-- "5" is a string -->

Stimulus converts based on type declaration:

static values = { 
    count: Number,  // Converted to number
    enabled: Boolean  // "true"/"false" converted to boolean
}

Boolean Values

<div data-toggle-enabled-value="true">   ✅ Works
<div data-toggle-enabled-value="false">  ✅ Works
<div data-toggle-enabled-value="">       ❌ Empty string, not false
<div>                                    <!-- Missing = uses default -->

Array/Object Values Need Valid JSON

<div data-chart-data-value='[1,2,3]'>           ✅ Valid JSON
<div data-chart-data-value="[1,2,3]">           ✅ Also works
<div data-chart-data-value='{"a": 1}'>          ✅ Valid JSON
<div data-chart-data-value='{a: 1}'>            ❌ Invalid JSON
<div data-chart-data-value="{{ data }}">        ❌ May produce invalid JSON
<div data-chart-data-value="{{ data|json_encode|e('html_attr') }}">  ✅ Twig

Value Changed Fires on Connect

countValueChanged(value, previousValue) {
    // On connect: previousValue is undefined
    if (previousValue === undefined) return;  // Skip initial call if needed
}

Action Gotchas

Forgetting Event Parameter

// ❌ Can't access event
submit() {
    // event is undefined
}

// ✅ Correct
submit(event) {
    event.preventDefault();
}

Action Parameters Are Always Strings

<button data-item-id-param="123" data-item-active-param="true">
delete(event) {
    event.params.id;      // "123" (string, not number)
    event.params.active;  // "true" (string, not boolean)
    
    // Convert manually if needed
    const id = parseInt(event.params.id, 10);
    const active = event.params.active === 'true';
}

Default Events Don't Work Everywhere

<!-- ❌ div has no default event -->
<div data-action="myController#click">

<!-- ✅ Specify event explicitly -->
<div data-action="click->myController#click">

Turbo Compatibility

Controller Connects Multiple Times

With Turbo Drive, controllers may connect/disconnect as you navigate:

export default class extends Controller {
    connect() {
        // This may run multiple times during navigation
        // Don't assume it only runs once
        console.log('Connected');
    }
}

Lazy Controllers + Turbo Issue

Known issue: lazy controllers may not load when navigating with Turbo if the controller wasn't on the first page visited.

Workarounds:

  1. Use eager loading for critical controllers
  2. Add data-turbo-track="reload" to force page reload
  3. Ensure controller is referenced on initial page

Morphing and Targets

When using Turbo morphing, targets may be replaced. Use [target]TargetConnected callbacks:

static targets = ['item']

itemTargetConnected(element) {
    // Reinitialize any third-party libraries on this element
    this.initializeElement(element);
}

Debugging Tips

Enable Debug Mode

// assets/bootstrap.js
import { Application } from '@hotwired/stimulus';

const app = Application.start();
app.debug = true;  // Logs lifecycle events to console

Check Controller Registration

// In browser console -- enables lifecycle logging
Stimulus.debug = true;

Verify Controller is Connected

connect() {
    console.log(`${this.identifier} connected to`, this.element);
}

Check Targets Exist

someMethod() {
    console.log('Has target?', this.hasOutputTarget);
    console.log('Targets:', this.outputTargets);
}

Inspect Element Data

connect() {
    console.log('Values:', {
        url: this.urlValue,
        count: this.countValue,
    });
    console.log('Element dataset:', this.element.dataset);
}

Common Console Errors

Error Cause
Cannot read property 'xyz' of undefined Target doesn't exist or is out of scope
this.xyzTarget is not a function Typo in target name or not declared in static targets
Controller not found: xyz File naming mismatch or controller not registered

Performance Tips

Use Lazy Loading for Heavy Controllers

/* stimulusFetch: 'lazy' */
import { Controller } from '@hotwired/stimulus';
import HeavyLibrary from 'heavy-library';  // Only loaded when needed

Debounce Expensive Operations

connect() {
    this.debouncedSearch = this.debounce(this.search.bind(this), 300);
}

debounce(fn, wait) {
    let timeout;
    return (...args) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn(...args), wait);
    };
}

Use disconnect() to Clean Up

Always remove event listeners, clear intervals, abort fetches:

connect() {
    this.abortController = new AbortController();
    fetch(url, { signal: this.abortController.signal });
}

disconnect() {
    this.abortController.abort();
}

Avoid Querying DOM in Loops

// ❌ Slow
items.forEach(item => {
    document.querySelector(`[data-id="${item.id}"]`).classList.add('active');
});

// ✅ Better - use targets
this.itemTargets.forEach(target => {
    target.classList.add('active');
});

Third-Party Libraries

Initializing Libraries on Connect

import Choices from 'choices.js';

export default class extends Controller {
    connect() {
        this.choices = new Choices(this.element);
    }

    disconnect() {
        this.choices.destroy();  // Clean up!
    }
}

Re-initializing After Turbo Navigation

static targets = ['select']

selectTargetConnected(element) {
    // Called when target added to DOM (including after Turbo morphing)
    element._choices = new Choices(element);
}

selectTargetDisconnected(element) {
    element._choices?.destroy();
}

Source: SKILL.md on GitHub

No alerts15d4 checks · Risk SAFE
  • Gen Agent Trust Hub15d

    This skill provides comprehensive documentation and implementation patterns for the Stimulus JavaScript framework. It covers lifecycle management, event handling, and common UI components using standard, secure web development practices and well-known libraries.

  • Socket15d

    No alerts

  • Snyk15d

    Risk: LOW · No issues

  • Runlayer7mo

    4 files scanned · No issues

Signed by skilld at 6d7ef8e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 4 months ago
Other metadata
metadata
{
  "author": "Simon Andre",
  "email": "smn.andre@gmail.com",
  "url": "https://smnandre.dev",
  "version": "1.2.0"
}

README badge

README badge for smnandre/symfony-ux-skills/stimulus