@smnandre/stimulus-delegation
v1.0.0
Published
Efficient event delegation for Stimulus controllers - Automatic cleanup and dynamic content support.
Maintainers
Readme
Stimulus Delegation: useDelegation Mixin
A TypeScript-compatible mixin 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 mixin 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.
Features
- 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
Installation
Using npm
npm install @smnandre/stimulus-delegationUsing JSDeliver
If 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';Basic Usage
import {Controller} from '@hotwired/stimulus';
import {useDelegation, DelegationController} from '@smnandre/stimulus-delegation';
export default class extends Controller implements DelegationController {
delegatedEvents?: Map<string, EventListener>;
initialize() {
Object.assign(this, useDelegation);
}
connect() {
// Set up event delegation
this.delegate('click', '.btn[data-action]', this.handleButtonClick)
.delegate('input', 'input[type="text"]', this.handleTextInput)
.delegate('change', 'select', this.handleSelectChange);
}
disconnect() {
// Clean up all delegated events
this.undelegateAll();
}
handleButtonClick(event: Event, target: Element) {
const action = (target as HTMLElement).dataset.action;
console.log(`Button clicked: ${action}`);
}
handleTextInput(event: Event, target: Element) {
const input = target as HTMLInputElement;
console.log(`Input changed: ${input.name} = ${input.value}`);
}
handleSelectChange(event: Event, target: Element) {
const select = target as HTMLSelectElement;
console.log(`Selection: ${select.value}`);
}
}API Reference
Methods
delegate(eventType, selector, handler)
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);undelegate(eventType, selector)
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');undelegateAll()
Removes all delegated event listeners. Call this in your controller's disconnect() method.
- Returns:
this- For method chaining
disconnect()
{
this.undelegateAll();
}Advanced Usage
Complex Selectors
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);Nested Elements
The mixin 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);
handleCardClick(event: Event, target: Element){
// target will be the .card element even if you click the span or button
const cardId = (target as HTMLElement).dataset.id;
console.log(`Card ${cardId} clicked`);
}Dynamic Content
Delegation automatically works with dynamically added elements:
connect() {
this.delegate('click', '.dynamic-btn', this.handleDynamic);
}
addNewButton() {
const button = document.createElement('button');
button.className = 'dynamic-btn';
button.textContent = 'New Button';
this.element.appendChild(button);
// Event delegation automatically works!
}Event Handler Context
Handlers are bound to the controller instance:
handleClick(event: Event, target: Element)
{
// `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;
}TypeScript Integration
Interface Implementation
import {useDelegation, DelegationController} from './mixins/use-delegation';
interface MyController extends DelegationController {
handleClick(event: Event, target: Element): void;
handleInput(event: Event, target: Element): void;
}
export default class extends Controller implements MyController {
delegatedEvents?: Map<string, EventListener>;
initialize() {
Object.assign(this, useDelegation);
}
// ... rest of implementation
}Helper Type
import {WithDelegation} from './mixins/use-delegation';
type MyControllerWithDelegation = WithDelegation<Controller> & {
someCustomMethod(): void;
};Real-World Examples
Todo List Controller
export default class extends Controller {
initialize() {
Object.assign(this, useDelegation);
}
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);
}
disconnect() {
this.undelegateAll();
}
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();
}
}Data Table Controller
export default class extends Controller {
initialize() {
Object.assign(this, useDelegation);
}
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;
}
}
}Testing
Unit Tests
import {describe, it, expect, vi} from 'vitest';
import {useDelegation} from './use-delegation';
describe('useDelegation', () => {
it('delegates events correctly', () => {
const controller = createTestController();
const handler = vi.fn();
controller.delegate('click', '.btn', handler);
const button = controller.element.querySelector('.btn');
button.click();
expect(handler).toHaveBeenCalledWith(
expect.any(MouseEvent),
button
);
});
});E2E Tests
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');
});Performance Considerations
- 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
Troubleshooting
Events Not Firing
- 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
Memory Leaks
Always call undelegateAll() in your disconnect() method:
disconnect()
{
this.undelegateAll(); // Essential for cleanup
}TypeScript Errors
Ensure proper interface implementation:
// ✅ Correct
class MyController extends Controller implements DelegationController {
delegatedEvents?: Map<string, EventListener>;
}
// ❌ Missing interface implementation
class MyController extends Controller {
// TypeScript errors will occur
}License
MIT License - feel free to use in your projects.
