npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smnandre/stimulus-delegation

v1.0.0

Published

Efficient event delegation for Stimulus controllers - Automatic cleanup and dynamic content support.

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

Using 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

  1. Check selector specificity: Ensure your CSS selector matches the intended elements
  2. Verify event bubbling: Some events don't bubble (e.g., focus, blur)
  3. 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.