A function for Stimulus controllers that provides efficient event delegation capabilities. Handle events on dynamically added elements and nested structures without manual event listener management.
Tip
This function helps you wire up DOM event delegation in Stimulus controllers, both declaratively and imperatively, without the need for additional build steps or decorators.
If you can, please consider sponsoring this project to support its development and maintenance.
- Event Delegation: Listen to events on child elements using CSS selectors
- Dynamic Content Support: Automatically handles dynamically added elements
- Automatic Cleanup: Proper memory management with lifecycle-aware cleanup
- TypeScript Support: Fully typed with proper interfaces and generics
- Method Chaining: Fluent API for setting up multiple delegations
- Performance Optimized: Uses event bubbling and
closest()for efficient matching
npm install @smnandre/stimulus-delegationIf you prefer to use a CDN, you can import it directly from JSDeliver:
import {useDelegation} from 'https://cdn.jsdelivr.net/npm/@smnandre/stimulus-delegation@latest';import {Controller} from '@hotwired/stimulus';
import {useDelegation} from '@smnandre/stimulus-delegation';
export default class extends Controller {
initialize() {
// Pass the controller instance directly to the function
useDelegation(this);
}
connect() {
// Still use the fluent API for setting up delegations
this.delegate('click', '.btn[data-action]', this.handleButtonClick)
.delegate('input', 'input[type="text"]', this.handleTextInput);
}
handleButtonClick(event, target) {
console.log(`Button clicked: ${target.dataset.action}`);
}
handleTextInput(event, target) {
console.log(`Input value: ${target.value}`);
}
}Sets up event delegation for the specified event type and CSS selector.
- eventType:
string- The event type to listen for (e.g., 'click', 'input') - selector:
string- CSS selector to match target elements - handler:
DelegationHandler- Function to call when event occurs - Returns:
this- For method chaining
this.delegate('click', '.delete-btn', this.handleDelete);Removes a specific delegated event listener.
- eventType:
string- The event type - selector:
string- CSS selector that was used - Returns:
this- For method chaining
this.undelegate('click', '.delete-btn');Removes all delegated event listeners. This is handled automatically when the controller disconnects, so you don't need to call it manually unless you want to remove all delegations before disconnect.
- Returns:
this- For method chaining
Use any valid CSS selector for precise targeting:
// Attribute selectors
this.delegate('click', '[data-action="save"]', this.handleSave);
// Class combinations
this.delegate('click', '.btn.primary:not(.disabled)', this.handlePrimary);
// Descendant selectors
this.delegate('change', 'form .required-field', this.handleRequired);
// Multiple selectors (use separate calls)
this.delegate('click', '.edit-btn', this.handleEdit);
this.delegate('click', '.delete-btn', this.handleDelete);The delegation mechanism uses element.closest(selector) to find matching ancestors:
// HTML
<div class="card" data-id="123">
<h3>Card Title</h3>
<span class="clickable">Click anywhere in card</span>
<div class="actions">
<button>Edit</button>
</div>
</div>// Controller
this.delegate('click', '.card', this.handleCardClick);
// Handler function
function handleCardClick(event, target) {
// target will be the .card element even if you click the span or button
const cardId = target.dataset.id;
console.log(`Card ${cardId} clicked`);
}Delegation automatically works with dynamically added elements:
// Connect method
function connect() {
this.delegate('click', '.dynamic-btn', this.handleDynamic);
}
// Add new button method
function addNewButton() {
const button = document.createElement('button');
button.className = 'dynamic-btn';
button.textContent = 'New Button';
this.element.appendChild(button);
// Event delegation automatically works!
}Handlers are bound to the controller instance:
// Handler function
function handleClick(event, target) {
// `this` refers to the controller
this.someMethod();
console.log(this.element); // Controller's element
// Access the event and matched target
event.preventDefault();
const buttonText = target.textContent;
}To ensure type safety in your TypeScript project, you can inform the compiler that your controller has been enhanced with delegation capabilities.
Declare the delegation methods on your controller class and TypeScript will recognize them.
import { Controller } from '@hotwired/stimulus'
import { useDelegation, DelegationController } from '@smnandre/stimulus-delegation'
export default class extends Controller {
// Inform TypeScript about the added methods
delegate!: DelegationController['delegate']
undelegate!: DelegationController['undelegate']
undelegateAll!: DelegationController['undelegateAll']
initialize() {
useDelegation(this)
}
connect() {
this.delegate('click', '.btn', this.handleClick)
}
handleClick(event: Event, target: Element) {
// handler logic
}
}export default class extends Controller {
initialize() {
useDelegation(this)
}
connect() {
this.delegate('click', '.todo-toggle', this.toggleTodo)
.delegate('click', '.todo-delete', this.deleteTodo)
.delegate('dblclick', '.todo-label', this.editTodo)
.delegate('keypress', '.todo-edit', this.saveEdit)
.delegate('blur', '.todo-edit', this.cancelEdit);
}
toggleTodo(event: Event, target: Element) {
const checkbox = target as HTMLInputElement;
const todoItem = checkbox.closest('.todo-item');
todoItem?.classList.toggle('completed', checkbox.checked);
}
deleteTodo(event: Event, target: Element) {
const todoItem = target.closest('.todo-item');
todoItem?.remove();
}
}export default class extends Controller {
initialize() {
useDelegation(this)
}
connect() {
this.delegate('click', 'th[data-sortable]', this.handleSort)
.delegate('click', '.pagination-btn', this.handlePagination)
.delegate('change', '.row-checkbox', this.handleRowSelect)
.delegate('click', '.action-btn', this.handleRowAction);
}
handleSort(event: Event, target: Element) {
const column = (target as HTMLElement).dataset.column;
// Sort logic here
}
handleRowAction(event: Event, target: Element) {
const action = (target as HTMLElement).dataset.action;
const row = target.closest('tr');
const rowId = row?.dataset.id;
switch (action) {
case 'edit':
this.editRow(rowId);
break;
case 'delete':
this.deleteRow(rowId);
break;
}
}
}import { describe, it, expect, vi } from 'vitest'
import { useDelegation } from '@smnandre/stimulus-delegation'
import { Controller } from '@hotwired/stimulus'
describe('useDelegation', () => {
it('delegates events correctly', () => {
// Create a test controller
const controller = {
element: document.createElement('div'),
disconnect: () => {}
} as unknown as Controller
// Add a button to test with
const button = document.createElement('button')
button.className = 'btn'
controller.element.appendChild(button)
// Apply delegation
useDelegation(controller)
// Set up delegation and handler
const handler = vi.fn()
controller.delegate('click', '.btn', handler)
// Trigger event
button.click()
// Verify handler was called with correct arguments
expect(handler).toHaveBeenCalledWith(
expect.any(MouseEvent),
button
)
})
})import {test, expect} from '@playwright/test';
test('delegation works with dynamic content', async ({page}) => {
await page.goto('/delegation-test');
// Add dynamic button
await page.click('#add-button');
// Click dynamic button
await page.click('.dynamic-btn');
await expect(page.locator('#log')).toContainText('Dynamic button clicked');
});- Event Bubbling: Uses native event bubbling for efficiency
- Single Listener: One listener per event type, regardless of selector count
- Memory Management: Automatic cleanup prevents memory leaks
- Selector Optimization: Use specific selectors for better performance
- Check selector specificity: Ensure your CSS selector matches the intended elements
- Verify event bubbling: Some events don't bubble (e.g.,
focus,blur) - Element containment: Events only fire for elements within the controller's scope
Released under the MIT License by Simon André.