Stimulus Cross-Controller Communication — Events, Outlets, and Patterns
Stimulus cross-controller communication lets separate controllers talk using custom events, outlets, or a shared event bus — no global state required.
What You’ll Learn
You’ll master four communication patterns: custom events (child-to-parent), outlets (direct controller-to-controller), event bus (decoupled messaging), and shared data attributes. You’ll know which pattern fits each scenario.
Why Cross-Controller Communication Matters
In real apps, controllers rarely work in isolation. A form controller needs to tell a list controller to refresh. A notification controller needs to hear from everywhere. A tab controller needs to coordinate with a content panel.
Security note: Understanding Stimulus Communication helps build more secure applications — a core principle at DodaTech, where tools like Durga Antivirus Pro and Doda Browser rely on solid implementation practices.
Without structured communication, developers fall back to global variables or jQuery-style $(document).trigger() — which works but creates spaghetti code. Stimulus gives you clean patterns that are testable and maintainable.
The Doda Browser extension uses cross-controller communication to coordinate panel resizing, bookmark syncing, and extension state updates across independent UI components without coupling them together.
flowchart LR
A["Controller A (form)"] -- "dispatches custom event" --> B["Controller B (list)"]
C["Controller C (modal)"] -- "outlet reference" --> D["Controller D (form)"]
E["Controller E (actions)"] -- "event bus" --> F["Controller F (notifications)"]
G["Controller G (counter)"] -- "shared data attributes" --> H["Controller H (totals)"]
style A fill:#4f46e5,color:#fff
style B fill:#059669,color:#fff
style C fill:#ea580c,color:#fff
style F fill:#dc2626,color:#fff
Pattern 1: Custom Events (Parent-Child Communication)
Custom events are the recommended way for a child controller to communicate upward to a parent. The child dispatches a JavaScript CustomEvent that bubbles up the DOM tree, and the parent catches it with a data-action descriptor.
How It Works
Think of it like shouting up a stairwell. The child controller yells (dispatches an event), and any parent controller listening on that landing hears it.
// Child controller: dispatches an event
application.register('form', class extends Stimulus.Controller {
submit(event) {
event.preventDefault()
const data = { name: this.nameTarget.value }
this.dispatch('submitted', { detail: { data } })
}
})The this.dispatch() method creates a CustomEvent named form:submitted (with the controller name as prefix) and fires it on the controller’s element. The detail property carries the data.
The parent controller listens using a colon-separated event name in data-action:
<div data-controller="list"
data-action="form:submitted->list#addItem">
<div data-controller="form">
<input data-form-target="name" type="text">
<button data-action="click->form#submit">Add</button>
</div>
<ul data-list-target="items"></ul>
</div>application.register('list', class extends Stimulus.Controller {
static targets = ['items']
addItem(event) {
const item = document.createElement('li')
item.textContent = event.detail.data.name
this.itemsTarget.appendChild(item)
}
})Why this works: The event bubbles from the form controller’s element up through the DOM tree to the list controller’s element. Stimulus’s action system picks it up because the event name matches form:submitted.
Event Naming Convention
Use controller:action format: modal:open, form:submitted, notification:show. The colon prevents naming collisions.
Configuring dispatch()
this.dispatch('show', {
detail: { message: 'Hello' },
prefix: 'notification', // Custom prefix (default: controller name)
bubbles: true, // Bubble up the DOM (default: true)
cancelable: true // Allow preventDefault (default: true)
})Pattern 2: Outlets (Direct Controller Access)
Outlets are like targets — but for other controllers. They let one controller directly access another controller’s methods and properties.
Setting Up Outlets
<div data-controller="gallery">
<div data-controller="thumbnail"
data-gallery-outlet=".thumbnail">
<img src="thumb1.jpg" data-action="click->gallery#select">
</div>
<div data-controller="thumbnail"
data-gallery-outlet=".thumbnail">
<img src="thumb2.jpg" data-action="click->gallery#select">
</div>
<div data-gallery-target="preview">
<img src="" alt="Preview">
</div>
</div>The data-gallery-outlet=".thumbnail" tells the gallery controller: “find elements matching .thumbnail that have their own Stimulus controllers.”
Declaring Outlets
application.register('gallery', class extends Stimulus.Controller {
static targets = ['preview']
static outlets = ['thumbnail']
select(event) {
const img = event.currentTarget.querySelector('img')
this.previewTarget.src = img.src
// Call methods on all thumbnail outlets
this.thumbnailOutlets.forEach(outlet => {
outlet.markSelected(outlet.element === event.currentTarget)
})
}
})
application.register('thumbnail', class extends Stimulus.Controller {
static classes = ['selected']
markSelected(selected) {
this.element.classList.toggle(this.selectedClass, selected)
}
})Why outlets instead of events? When you need to call a specific method on a specific controller. Outlets give you a direct reference — like handing someone a phone number instead of broadcasting on a radio channel.
Outlet Properties
| Property | Description |
|---|---|
this.thumbnailOutlet | First matching outlet controller |
this.thumbnailOutlets | Array of all matching outlet controllers |
this.hasThumbnailOutlet | Boolean check |
Outlet Callbacks
Outlets have their own lifecycle callbacks — they fire when the outlet controller connects or disconnects:
class extends Stimulus.Controller {
static outlets = ['sidebar']
sidebarOutletConnected(outlet, element) {
console.log('Sidebar controller connected')
outlet.update(this.currentData)
}
sidebarOutletDisconnected(outlet, element) {
console.log('Sidebar controller disconnected')
}
}Outlet Selectors
Outlets use CSS selectors. Common patterns:
<!-- By data-controller value -->
<div data-controller="parent" data-parent-outlet="[data-controller='child']">
<!-- By CSS class -->
<div data-controller="parent" data-parent-outlet=".child-component">
<!-- Any CSS selector -->
<div data-controller="parent" data-parent-outlet="#main-content div[data-controller]">Pattern 3: Event Bus (Decoupled Communication)
For complex apps where controllers aren’t parent-child, an event bus provides decoupled messaging. Any controller can emit an event, and any controller can listen — no DOM hierarchy required.
// Simple event bus
class EventBus {
constructor() {
this.listeners = {}
}
on(event, callback) {
;(this.listeners[event] = this.listeners[event] || []).push(callback)
return () => this.off(event, callback)
}
off(event, callback) {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
}
emit(event, data) {
(this.listeners[event] || []).forEach(cb => cb(data))
}
}
window.bus = new EventBus()Using the Event Bus
// Listener (cleanup on disconnect)
application.register('notifications', class extends Stimulus.Controller {
connect() {
this._unsubscribe = window.bus.on('notify', (data) => {
this.show(data.message, data.type)
})
}
disconnect() {
this._unsubscribe()
}
show(message, type) { /* display notification */ }
})
// Emitter
application.register('action-btn', class extends Stimulus.Controller {
performAction() {
window.bus.emit('notify', {
message: 'Action complete!',
type: 'success'
})
}
})Why choose event bus over custom events? When the sender and receiver are in completely different parts of the DOM (or not in a parent-child relationship at all). The cost is a global singleton — so always clean up in disconnect().
Pattern 4: Shared Data Attributes
The simplest communication is through shared data attributes on a common ancestor:
<div data-controller="counter totals"
data-totals-count-value="0">
<div data-controller="item" data-item-index-value="0">
<button data-action="click->item#add">Add</button>
</div>
<div data-controller="totals">
Total: <span data-totals-target="display">0</span>
</div>
</div>The item controller can find its sibling by accessing the parent:
application.register('item', class extends Stimulus.Controller {
add() {
const totals = this.application.getControllerForElementAndIdentifier(
this.element.closest('[data-controller]'),
'totals'
)
if (totals) totals.increment()
}
})Choosing the Right Pattern
| Pattern | Best For | Coupling |
|---|---|---|
| Custom Events | Child → parent communication | Loose |
| Outlets | Direct controller-to-controller access | Tight |
| Event Bus | Decoupled, app-wide notifications | Loose |
| Shared Attributes | Simple sibling coordination | Medium |
Rule of thumb: Start with custom events. They’re the most Stimulus-idiomatic pattern. Move to outlets when you need direct method access. Use an event bus only when controllers are deeply nested or disconnected.
Stimulus vs React vs Alpine.js: Communication
| Approach | Stimulus | React | Alpine.js |
|---|---|---|---|
| Parent→Child | Outlets | Props | x-ref |
| Child→Parent | Custom events | Callback props | $dispatch |
| Siblings | Shared ancestor events | Lifting state | Global store |
| Global | Event bus | Context/Redux | $store |
| Boilerplate | Low | Medium | Low |
Common Mistakes
1. Not cleaning up event bus subscriptions
// ❌ Subscription persists after disconnect
connect() {
window.bus.on('notify', this.handleNotify)
}
// ✅ Clean up
connect() {
this._unsub = window.bus.on('notify', this.handleNotify.bind(this))
}
disconnect() {
this._unsub()
}2. Dispatching events with bubbles: false
// ❌ Won't reach parent controllers
this.dispatch('custom', { bubbles: false })
// ✅ Allow bubbling
this.dispatch('custom', { bubbles: true })3. Using outlets without checking they exist
// ❌ Crashes if no sidebar outlet is on the page
this.sidebarOutlet.update()
// ✅ Guard first
if (this.hasSidebarOutlet) {
this.sidebarOutlet.update()
}4. Creating infinite event loops
A controller dispatches an event, which triggers another controller, which dispatches back to the first:
// ❌ Infinite loop!
this.dispatch('changed')
// Another controller listens and calls this.dispatch('changed') too
// ✅ Guard with a flag
this._processing = true
this.dispatch('changed')
setTimeout(() => { this._processing = false }, 0)5. Using global events when a custom event would work
// ❌ Too broad — every listener on the page gets this
document.dispatchEvent(new CustomEvent('update'))
// ✅ Scoped — only relevant parent controllers catch it
this.dispatch('update', { bubbles: true })6. Forgetting to bind methods for event bus callbacks
// ❌ `this` refers to the EventBus, not the controller
connect() {
window.bus.on('notify', this.show) // Wrong context!
}
// ✅ Bind `this` to the controller
connect() {
this._boundShow = this.show.bind(this)
window.bus.on('notify', this._boundShow)
}Practice Questions
1. What’s the difference between custom events and outlets?
Custom events provide loose coupling — the sender doesn’t know who receives. Outlets give direct access to another controller’s API. Use events for child→parent, outlets for sibling or direct API calls.
2. How do you prevent event memory leaks?
Always clean up global event listeners and event bus subscriptions in disconnect(). Store the unsubscribe function when subscribing.
3. When should you use an event bus?
When controllers are in completely different parts of the DOM (not parent-child), or when you need app-wide communication like a notification system.
4. What does this.dispatch('submitted') return?
It creates a CustomEvent named {controller}:submitted, dispatches it on the controller’s element, and returns the event object. It accepts detail, prefix, bubbles, and cancelable options.
🏆 Challenge
Build a shopping cart with two controllers: cart-item (increment/decrement buttons) and cart-total (displays sum). Use custom events for item → total communication. The cart-total should listen for cart-item:updated events.
FAQ
Try It Yourself
A complete notification system demonstrating all four communication patterns:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stimulus Communication Demo</title>
<script defer src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.x.x/dist/stimulus.umd.min.js"></script>
</head>
<body style="font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem;">
<h1>Communication Demo</h1>
<div data-controller="notifications"
data-action="notify@document->notifications#show">
<div id="notification-area" data-notifications-target="area"
style="min-height: 60px;"></div>
</div>
<div data-controller="sender">
<h2>Send a Notification</h2>
<input type="text" data-sender-target="message" placeholder="Your message" style="width: 200px;">
<button data-action="click->sender#sendEvent">Via Custom Event</button>
<button data-action="click->sender#sendBus">Via Event Bus</button>
</div>
<div id="log" data-controller="log"
data-action="log@document->log#add"
style="margin-top: 1rem; font-family: monospace; font-size: 0.85rem;"></div>
<script>
(() => {
// Event Bus
class Bus {
constructor() { this._l = {} }
on(e, cb) { (this._l[e] = this._l[e] || []).push(cb); return () => this.off(e, cb) }
off(e, cb) { if (this._l[e]) this._l[e] = this._l[e].filter(c => c !== cb) }
emit(e, d) { (this._l[e] || []).forEach(cb => cb(d)) }
}
window.bus = new Bus()
const app = Stimulus.Application.start()
app.register('sender', class extends Stimulus.Controller {
static targets = ['message']
sendEvent() {
const msg = this.messageTarget.value || 'Hello!'
document.dispatchEvent(new CustomEvent('notify', { detail: { message: msg, method: 'event' } }))
this._log(`Sent via event: "${msg}"`)
}
sendBus() {
const msg = this.messageTarget.value || 'Hello!'
window.bus.emit('notify', { message: msg, method: 'bus' })
this._log(`Sent via bus: "${msg}"`)
}
_log(msg) {
document.dispatchEvent(new CustomEvent('log', { detail: { message: msg } }))
}
})
app.register('notifications', class extends Stimulus.Controller {
static targets = ['area']
connect() {
this._unsub = window.bus.on('notify', (data) => this._render(data))
}
disconnect() { this._unsub() }
show(event) {
this._render(event.detail)
}
_render(data) {
const el = document.createElement('div')
el.textContent = `[${data.method}] ${data.message}`
el.style.cssText = 'padding: 0.5rem; margin: 0.25rem 0; background: #e0f2fe; border-radius: 4px;'
this.areaTarget.appendChild(el)
setTimeout(() => el.remove(), 3000)
}
})
app.register('log', class extends Stimulus.Controller {
static targets = []
add(event) {
const el = document.createElement('div')
el.textContent = `[${new Date().toLocaleTimeString()}] ${event.detail.message}`
this.element.appendChild(el)
}
})
})()
</script>
</body>
</html>Try this: Type a message and click both buttons. Notice both methods work. The notification area shows the message, and the log tracks what happened.
What’s Next
| Topic | Description |
|---|---|
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-patterns/ | Real-world patterns: modals, autocomplete, infinite scroll |
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-lifecycle-state/ | Master lifecycle callbacks for proper setup/cleanup |
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-getting-started/ | Review controllers, targets, and actions fundamentals |
| Alpine.js | Compare Alpine.js $dispatch with Stimulus events |
| HTMX | Server-driven AJAX with Stimulus client enhancement |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. The event-driven architecture in this tutorial mirrors how the Doda Browser extension coordinates bookmark sync, tab management, and settings panels across independent UI components.
What’s Next
Congratulations on completing this Stimulus Communication tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro