Skip to content
Stimulus Cross-Controller Communication — Events, Outlets, and Patterns

Stimulus Cross-Controller Communication — Events, Outlets, and Patterns

DodaTech Updated Jun 6, 2026 10 min read

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
  
Prerequisites: Complete https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-getting-started/ and https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-lifecycle-state/ first. You should be comfortable with controllers, targets, values, and lifecycle callbacks. Basic JavaScript knowledge (CustomEvent, addEventListener) helps.

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

PropertyDescription
this.thumbnailOutletFirst matching outlet controller
this.thumbnailOutletsArray of all matching outlet controllers
this.hasThumbnailOutletBoolean 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

PatternBest ForCoupling
Custom EventsChild → parent communicationLoose
OutletsDirect controller-to-controller accessTight
Event BusDecoupled, app-wide notificationsLoose
Shared AttributesSimple sibling coordinationMedium

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

ApproachStimulusReactAlpine.js
Parent→ChildOutletsPropsx-ref
Child→ParentCustom eventsCallback props$dispatch
SiblingsShared ancestor eventsLifting stateGlobal store
GlobalEvent busContext/Redux$store
BoilerplateLowMediumLow

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

When should I use outlets vs custom events?

Use outlets when you need direct access to another controller’s API (tight coupling). Use custom events when you want a loose “I don’t care who hears this” approach. Outlets are like a direct phone call; events are like a radio broadcast.

Can outlets work across different controller types?

Yes. The outlet selector matches elements with any data-controller attribute. A gallery controller can have outlets for thumbnail controllers, and vice versa.

How do I prevent events from triggering themselves?

Add a guard in the handler: check event.target or use a processing flag. See “Common Mistakes” #4 above.

Can I use Stimulus’s dispatch with non-Stimulus code?

Yes. this.dispatch() creates standard CustomEvent objects. Any JavaScript code can listen with addEventListener('eventname', handler).

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

TopicDescription
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.jsCompare Alpine.js $dispatch with Stimulus events
HTMXServer-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