A modern, type-safe way to handle custom Turbo Stream actions directly in your Stimulus controllers. Enjoy clean, controller-scoped logic, automatic lifecycle management, and seamless integration with Turbo and Stimulus.
- ✨ Controller-scoped stream actions: Register actions directly on your Stimulus controllers
- 🧹 Automatic cleanup: Actions are managed with the controller lifecycle
- 🧑💻 TypeScript support: Benefit from full typings for actions and handler signatures
- 🧩 Flexible configuration: Configure per-action options and register actions dynamically
- 🧪 Easy testing: Test controllers in isolation without global state
- 🚀 Works with plain Stimulus: No decorators or build steps required
import { Controller } from '@hotwired/stimulus';
import { useStreamActions } from '@smnandre/stimulus-stream-actions';
export default class CartController extends Controller {
static streamActions = {
'add_to_cart': 'addToCart',
'remove_from_cart': 'removeFromCart'
};
initialize() {
useStreamActions(this);
}
addToCart({ render }) {
const productId = render.getAttribute('product-id');
// ...add to cart logic...
}
removeFromCart({ render }) {
const productId = render.getAttribute('product-id');
// ...remove from cart logic...
}
}
Custom Turbo Stream actions typically require global registration, creating pollution and maintenance headaches:
import { StreamActions } from "@hotwired/turbo";
StreamActions.closeModal = function() { /* ... */ }
StreamActions.updateCart = function() { /* ... */ }
Problems:
- Global namespace pollution
- No automatic cleanup
- Hard to test in isolation
- No controller scoping
Scope stream actions directly to your Stimulus controllers with automatic lifecycle management:
static streamActions = {
'close_modal': 'closeModal',
'update_cart': 'updateCart',
'show_notification': 'showNotification'
}
- No Global Pollution: Actions are scoped to specific controllers
- Auto Cleanup: Actions automatically removed when controller disconnects
- Better Testing: Each controller can be tested in isolation
- Type Safety: Full TypeScript support with proper typing
- Zero Build: No decorators or extra tooling required
- Controller Context: Access to
this.element
, targets, values, etc.
Every stream action handler receives a single object argument:
handler({ target, event, render }) {
// target: The Turbo Stream target element (if any)
// event: The original CustomEvent (turbo:before-stream-render)
// render: The <turbo-stream> element itself
}
- target: The element targeted by the Turbo Stream (may be null)
- event: The original CustomEvent for advanced use (e.g., calling
preventDefault()
manually) - render: The
<turbo-stream>
element. Userender.getAttribute('attr')
to access attributes, andrender.innerHTML
for content.
You can destructure only what you need:
closeModal({ render }) { ... }
npm install @smnandre/stimulus-stream-actions
Or via CDN:
import { useStreamActions } from 'https://cdn.jsdelivr.net/npm/@smnandre/stimulus-stream-actions@latest';
import { Controller } from '@hotwired/stimulus';
import { useStreamActions } from '@smnandre/stimulus-stream-actions';
export default class NotificationController extends Controller {
static streamActions = {
'show_notification': 'showNotification',
'hide_all_notifications': 'hideAll'
};
initialize() {
useStreamActions(this);
}
showNotification({ render }) {
const message = render.getAttribute('message');
const type = render.getAttribute('type') || 'info';
// Use controller context - this.element, this.targets, etc.
const notification = this.element.querySelector('[data-notification-template]').cloneNode(true);
notification.textContent = message;
notification.className = `notification notification--${type}`;
this.element.appendChild(notification);
}
hideAll() {
this.element.querySelectorAll('.notification').forEach(notification => {
notification.remove();
});
}
}
HTML Response:
<turbo-stream action="show_notification" message="Order completed!" type="success">
<template></template>
</turbo-stream>
<turbo-stream action="hide_all_notifications">
<template></template>
</turbo-stream>
The stream elements can be generated by any backend framework that supports Turbo Streams.
You can use advanced configuration for each action:
static streamActions = {
'close_modal': 'closeModal',
'update_content': { method: 'updateContent', preventDefault: false },
'highlight_modal': 'highlightModal'
};
- method: The controller method to call
- preventDefault: If true (default), prevents the default Turbo Stream rendering. Set to false to allow both your handler and the default behavior.
export default class ModalController extends Controller {
static streamActions = {
'close_modal': 'closeModal',
'update_content': { method: 'updateContent', preventDefault: false },
'highlight_modal': 'highlightModal'
};
initialize() {
useStreamActions(this);
}
closeModal({ render }) {
const modalId = render.getAttribute('modal-id');
if (modalId) {
this.element.querySelector(`#${modalId}`)?.remove();
} else {
this.element.querySelectorAll('[data-modal]').forEach(modal => modal.remove());
}
}
updateContent({ render }) {
const content = render.innerHTML;
this.element.querySelectorAll('[data-modal-content]').forEach(area => {
area.innerHTML = content;
});
}
highlightModal({ render }) {
const duration = Number(render.getAttribute('duration')) || 2000;
this.element.querySelectorAll('[data-modal]').forEach(modal => {
modal.classList.add('modal--highlighted');
setTimeout(() => modal.classList.remove('modal--highlighted'), duration);
});
}
}
For scenarios where actions depend on controller state or values:
import { Controller } from '@hotwired/stimulus';
import { useCustomStreamActions } from '@smnandre/stimulus-stream-actions';
export default class DynamicController extends Controller {
static values = { animated: Boolean, mode: String };
initialize() {
const actions = this.animatedValue ? {
'remove_item': 'animatedRemove',
'add_item': 'animatedAdd'
} : {
'remove_item': 'instantRemove',
'add_item': 'instantAdd'
};
if (this.modeValue === 'admin') {
actions['bulk_delete'] = 'bulkDelete';
actions['bulk_update'] = 'bulkUpdate';
}
useCustomStreamActions(this, actions);
}
animatedRemove({ render }) {
const itemId = render.getAttribute('item-id');
const duration = Number(render.getAttribute('duration')) || 300;
const items = this.element.querySelectorAll(`[data-item-id="${itemId}"]`);
items.forEach(item => {
item.style.transition = `opacity ${duration}ms ease-out`;
item.style.opacity = '0';
setTimeout(() => item.remove(), duration);
});
}
instantRemove({ render }) {
const itemId = render.getAttribute('item-id');
this.element.querySelectorAll(`[data-item-id="${itemId}"]`).forEach(item => {
item.remove();
});
}
bulkDelete({ render }) {
const selector = render.getAttribute('selector');
this.element.querySelectorAll(selector).forEach(item => item.remove());
}
}
export default class TabsController extends Controller {
static targets = ['tab', 'panel'];
static streamActions = {
'activate_tab': 'activateTab',
'update_tab_content': 'updateTabContent',
'add_notification_badge': 'addBadge'
};
activateTab({ render }) {
const tabId = render.getAttribute('tab-id');
this.tabTargets.forEach(tab => tab.classList.remove('active'));
this.panelTargets.forEach(panel => panel.classList.remove('active'));
const activeTab = this.element.querySelector(`[data-tab-id="${tabId}"]`);
const activePanel = this.element.querySelector(`[data-panel-id="${tabId}"]`);
activeTab?.classList.add('active');
activePanel?.classList.add('active');
}
updateTabContent({ render }) {
const tabId = render.getAttribute('tab-id');
const content = render.innerHTML;
const panel = this.element.querySelector(`[data-panel-id="${tabId}"]`);
if (panel) panel.innerHTML = content;
}
addBadge({ render }) {
const tabId = render.getAttribute('tab-id');
const count = Number(render.getAttribute('count')) || 0;
const tab = this.element.querySelector(`[data-tab-id="${tabId}"]`);
if (tab) {
tab.querySelectorAll('.badge').forEach(badge => badge.remove());
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = count.toString();
tab.appendChild(badge);
}
}
}
You can also register controller methods for base Turbo Stream actions (like insert
, update
, etc.) using static streamActions
. If a controller method is registered for a base action, it will be called instead of Turbo's default behavior (unless you set preventDefault: false
).
Example:
export default class ListController extends Controller {
static streamActions = {
insert: 'handleInsert',
update: 'handleUpdate'
};
initialize() {
useStreamActions(this);
}
handleInsert({ render }) {
// Use render.querySelector('template') to get the template content
const template = render.querySelector('template');
if (template) {
this.element.appendChild(template.content.cloneNode(true));
}
}
handleUpdate({ render }) {
const template = render.querySelector('template');
if (template) {
this.element.innerHTML = '';
this.element.appendChild(template.content.cloneNode(true));
}
}
}
Enables stream actions defined in the static streamActions
property.
static streamActions = {
'action_name': 'methodName'
};
Programmatically registers stream actions for dynamic scenarios.
useCustomStreamActions(this, {
'dynamic_action': 'handleDynamicAction'
});
type StreamActionConfig = string | {
method: string; // Controller method name
preventDefault?: boolean; // Prevent default Turbo behavior (default: true)
};
type StreamActionMap = Record<string, StreamActionConfig>;
handler({ target, event, render }) {
// target: The Turbo Stream target element (if any)
// event: The original CustomEvent (turbo:before-stream-render)
// render: The <turbo-stream> element
// Example:
const value = render.getAttribute('custom-attribute');
const content = render.innerHTML;
// Use controller context
this.element.querySelector('...');
this.targets;
this.values;
}
By default, your handler will prevent the default Turbo Stream rendering. To allow both your handler and the default behavior, set preventDefault: false
in your action config:
static streamActions = {
'update_content': { method: 'updateContent', preventDefault: false }
};
If an error is thrown in your handler, it will be logged to the console in development mode for easier debugging.
- Registration: Controllers register with a global
StreamActionRegistry
on connect - Event Interception: The registry listens for
turbo:before-stream-render
events - Action Routing: When a custom action is detected, it routes to the appropriate controller method
- Cleanup: Controllers automatically unregister when they disconnect
Scope: Actions trigger for all <turbo-stream>
elements in the DOM. The turbo:before-stream-render
event bubbles to the document level, so any controller can handle any stream action.
npm install
npm test # Unit tests with Vitest
npm run test:e2e # End-to-end tests with Playwright
Full TypeScript support with proper type definitions:
import { Controller } from '@hotwired/stimulus';
import { useStreamActions, useCustomStreamActions } from '@smnandre/stimulus-stream-actions';
export default class MyController extends Controller {
static streamActions = {
'my_action': 'handleMyAction'
} as const;
initialize(): void {
useStreamActions(this);
}
handleMyAction({ target, event, render }: { target: Element | null, event: CustomEvent, render: Element }) {
// Fully typed method
// ...
}
}
Released under the MIT License by Simon André.