Skip to content

Lesson 7 — Cross-controller communication

The challenge

Stimulus controllers are deliberately small and focused. But real UIs often require coordination between controllers — a card being dragged should update a column’s card count; a form submission should trigger a toast; a modal closing should reset a filter.

In ERB, the data-* attributes that wire these interactions are scattered across templates, making the relationships hard to follow. In Phlex, each component owns its wiring — cross-controller communication is more explicit and traceable.

There are three patterns, each suited to different scenarios.

Pattern 1 — Outlets

Outlets let one controller hold a direct reference to another controller instance by CSS selector. Use outlets when two controllers have a tight, permanent relationship and one needs to call methods on the other.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// toast_trigger_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["toast"]

  notify() {
    this.toastOutlet.show("Action completed!")
  }
}

The outlet is declared via a data-*-outlet attribute pointing to a CSS selector:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
div(
  data: {
    controller:                 "toast-trigger",
    toast_trigger_toast_outlet: "#toast-container"
  }
) do
  Button(label: "Do something", type: "button",
         data: { action: "click->toast-trigger#notify" })
end

div(id: "toast-container", data: { controller: "toast-container" })

In Phlex, the outlet wiring lives in the component that needs it. ToastTrigger declares which element it connects to. ToastContainer renders with the matching id. The relationship is explicit in both components rather than scattered across templates.

Pattern 2 — Custom events

Custom events are the most loosely coupled pattern. A controller dispatches a named event; any controller on an ancestor element can listen for it. Use custom events when controllers are independent but occasionally need to notify the broader application.

1
2
3
4
5
6
7
8
// card_controller.js
export default class extends Controller {
  moved() {
    this.dispatch("moved", {
      detail: { cardId: this.element.dataset.id, position: this.newPosition }
    })
  }
}
1
2
3
4
5
6
7
8
9
// column_controller.js
export default class extends Controller {
  static targets = ["count"]

  cardMoved(event) {
    this.countTarget.textContent =
      this.element.querySelectorAll("[data-card]").length
  }
}

The listener is wired in the HTML using the eventName->controller#method action syntax:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
div(
  id:   dom_id(@column),
  data: {
    controller: "column",
    action:     "card:moved->column#cardMoved"
  }
) do
  @column.cards.each do |card|
    div(
      id:   dom_id(card),
      data: { controller: "card", id: card.id }
    )
  end
end

KanbanColumn declares that it listens for card:moved events. KanbanCard declares that it dispatches card:moved. Neither component needs to know about the other’s internal wiring — the relationship is visible from each component’s own data: attributes. This is a meaningful improvement over ERB where these attributes would be scattered across separate template files.

Pattern 3 — Common ancestor controller

When several child components need to coordinate through a shared parent, put the coordinating controller on the common ancestor element. Child controllers dispatch events upward; the parent orchestrates.

This is the pattern for the KanbanFlow board view:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// board_controller.js
export default class extends Controller {
  static targets = ["column"]

  cardMoved(event) {
    const { cardId, fromColumn, toColumn, position } = event.detail
    this.updateColumnCounts(fromColumn, toColumn)
    this.persistPosition(cardId, toColumn, position)
  }

  updateColumnCounts(fromId, toId) {
    this.columnTargets.forEach(col => {
      if (col.dataset.id === fromId || col.dataset.id === toId) {
        const count = col.querySelectorAll("[data-card]").length
        col.querySelector("[data-column-target='count']").textContent = count
      }
    })
  }

  persistPosition(cardId, columnId, position) {
    fetch("/cards/positions", {
      method:  "PATCH",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
      },
      body: JSON.stringify({ id: cardId, column_id: columnId, position })
    })
  }
}

In Views::Boards::Show:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
div(
  id:    dom_id(@board),
  class: "flex gap-4 overflow-x-auto pb-4",
  data:  {
    controller: "board",
    action:     "card:moved->board#cardMoved"
  }
) do
  @board.columns.each do |column|
    KanbanColumn(column: column)
  end
end

The board div is the common ancestor. It holds data-controller="board" and listens for card:moved events bubbling up from any KanbanCard anywhere in the board. KanbanColumn and KanbanCard dispatch events and let the parent handle coordination — they don’t reference the board controller at all.

Choosing a pattern

Situation Pattern
Two controllers always appear together, one calls methods on the other Outlets
Independent controllers that occasionally notify the broader UI Custom events
Multiple children coordinated by a parent Common ancestor

Custom events are the most broadly useful — loosely coupled, easy to debug, and require no direct reference between controllers. Reach for outlets only when you need to call a specific method on a specific controller instance. Use the common ancestor pattern for complex coordination like the board view.

Debugging cross-controller communication

Monitor custom events in the browser console:

1
document.addEventListener("card:moved", e => console.log(e.detail))

The Stimulus DevTools extension shows all active controllers and their outlet connections — invaluable for debugging outlet setup.