Skip to content

Stimulus Outlets — Complete Guide with Examples

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you'll learn about Stimulus Outlets. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Stimulus outlets let controllers reference other controllers on the page, enabling cross-controller communication through declarative HTML attributes.

What You'll Learn

  • Declaring outlets with static outlets = []
  • Accessing outlet controllers: this.nameOutlet, this.nameOutlets, this.hasNameOutlet
  • Outlet existence callbacks: {name}OutletConnected and {name}OutletDisconnected
  • Using outlets for cross-controller coordination
  • Scoping outlets within specific CSS selectors
  • Practical patterns for parent-child and sibling controller communication

Why It Matters

As your Stimulus application grows, controllers need to communicate. A search controller needs to tell a results controller to update. A form controller needs to tell a button controller to disable. Outlets provide clean, declarative cross-controller references without resorting to global events or direct DOM queries. In the Doda Browser extension, outlets coordinate between search inputs, results lists, pagination, and filter controllers.

Learning Path

flowchart LR
  A[Classes] --> B[Outlets]
  B --> C[Loading]
  C --> D[Testing]
  B --> E[Real Projects:
DodaTech Tools] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style E fill:#059669,color:#fff

What Are Outlets?

An outlet is a reference from one controller to another controller on a different element. It lets you call methods and access properties of another controller.

<div data-controller="search">
  <input type="text" data-action="input->search#query">
</div>

<div data-controller="results" data-search-outlet="results">
  <ul>
    <li data-results-target="item">Result 1</li>
  </ul>
</div>
app.register('search', class extends Controller {
  static outlets = ['results'];

  query() {
    if (this.hasResultsOutlet) {
      this.resultsOutlet.update('Search results...');
    }
  }
});

app.register('results', class extends Controller {
  static targets = ['item'];

  update(query) {
    console.log(`Updating results for: ${query}`);
  }
});

Teacher explains: The data-search-outlet="results" attribute on the <div data-controller="results"> says "this element is a results outlet for the search controller." Now search can access results through this.resultsOutlet.

Declaring Outlets

Basic Declaration

app.register('parent', class extends Controller {
  static outlets = ['child'];

  callChild() {
    if (this.hasChildOutlet) {
      this.childOutlet.doSomething();
    }
  }
});
<div data-controller="parent">
  <div data-controller="child" data-parent-outlet="child"></div>
  <button data-action="click->parent#callChild">Call Child</button>
</div>

Multiple Outlets

app.register('dashboard', class extends Controller {
  static outlets = ['chart', 'table', 'filter'];

  refresh() {
    this.chartOutlet.reload();
    this.tableOutlet.refresh();
    this.filterOutlet.reset();
  }
});
<div data-controller="dashboard">
  <div data-controller="chart" data-dashboard-outlet="chart">...</div>
  <div data-controller="table" data-dashboard-outlet="table">...</div>
  <div data-controller="filter" data-dashboard-outlet="filter">...</div>
</div>

Outlet Properties

For each declared outlet, Stimulus generates three properties:

Property Returns
this.nameOutlet First matching outlet controller
this.nameOutlets Array of all matching outlet controllers
this.hasNameOutlet Boolean, true if at least one outlet exists
app.register('manager', class extends Controller {
  static outlets = ['worker'];

  workWithFirst() {
    if (this.hasWorkerOutlet) {
      this.workerOutlet.start();
    }
  }

  workWithAll() {
    this.workerOutlets.forEach(worker => worker.start());
    console.log(`Started ${this.workerOutlets.length} workers`);
  }
});

Outlet Callbacks

Stimulus provides lifecycle callbacks for outlets:

nameOutletConnected(outlet) — Called When an Outlet Appears

app.register('manager', class extends Controller {
  static outlets = ['worker'];

  workerOutletConnected(outlet) {
    console.log('Worker outlet connected');
    outlet.assignTask({ id: Date.now(), type: 'data-process' });
  }

  workerOutletDisconnected(outlet) {
    console.log('Worker outlet disconnected, cancelling...');
    outlet.cancel();
  }
});

Real-world: Distributed Task Processing

app.register('task-manager', class extends Controller {
  static outlets = ['worker'];
  #taskQueue = [];

  workerOutletConnected(worker) {
    const task = this.#taskQueue.shift();
    if (task) {
      worker.process(task);
    }
  }

  workerOutletDisconnected(worker) {
    worker.cancel();
  }

  addTask(task) {
    const availableWorker = this.workerOutlets.find(w => w.idleValue);
    if (availableWorker) {
      availableWorker.process(task);
    } else {
      this.#taskQueue.push(task);
    }
  }
});

Scoping Outlets with CSS Selectors

Outlets can be scoped to specific CSS selectors using {name}Outlet with a selector:

app.register('editor', class extends Controller {
  static outlets = ['toolbar'];

  formatText(command) {
    if (this.hasToolbarOutlet) {
      this.toolbarOutlet.highlightButton(command);
    }
  }
});
<div data-controller="editor">
  <div data-controller="toolbar" data-editor-outlet="toolbar">...</div>
  <div data-controller="toolbar" data-editor-outlet="toolbar">...</div>
</div>

<!-- The first matching toolbar is used as the primary outlet -->

Outlet with Named Selectors

You can scope outlets to specific elements using CSS selectors in the outlet attribute:

<div data-controller="manager">
  <!-- Specific outlet targeting -->
  <div data-controller="worker"
       data-manager-outlet="worker.primary">Primary Worker</div>
  <div data-controller="worker"
       data-manager-outlet="worker.secondary">Secondary Worker</div>
</div>

Real-world: Search and Results Coordination

<div data-controller="search-form"
     data-search-form-min-length-value="3">
  <input type="text" data-search-form-target="input"
         data-action="input->search-form#search">

  <div data-controller="search-results"
       data-search-form-outlet="results">
    <ul data-search-results-target="list"></ul>
    <p data-search-results-target="empty" class="hidden">No results found</p>
    <p data-search-results-target="loading" class="hidden">Searching...</p>
  </div>

  <div data-controller="search-pagination"
       data-search-form-outlet="pagination">
    <!-- Pagination buttons rendered here -->
  </div>
</div>
app.register('search-form', class extends Controller {
  static targets = ['input'];
  static outlets = ['results', 'pagination'];
  static values = { minLength: Number };

  #debounceTimer = null;

  search() {
    clearTimeout(this.#debounceTimer);
    this.#debounceTimer = setTimeout(() => this.performSearch(), 300);
  }

  async performSearch() {
    const query = this.inputTarget.value.trim();

    if (query.length < this.minLengthValue) {
      this.resultsOutlet?.showEmpty();
      return;
    }

    this.resultsOutlet?.showLoading();

    try {
      const data = await this.fetchResults(query);
      this.resultsOutlet?.render(data.items);
      this.paginationOutlet?.render(data.total, data.page);
    } catch (error) {
      this.resultsOutlet?.showError(error.message);
    }
  }

  async fetchResults(query) {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    if (!response.ok) throw new Error('Search failed');
    return response.json();
  }
});

app.register('search-results', class extends Controller {
  static targets = ['list', 'empty', 'loading'];

  render(items) {
    this.loadingTarget.classList.add('hidden');
    if (items.length === 0) {
      this.showEmpty();
      return;
    }
    this.emptyTarget.classList.add('hidden');
    this.listTarget.innerHTML = items.map(i => `<li>${i.name}</li>`).join('');
  }

  showLoading() {
    this.loadingTarget.classList.remove('hidden');
    this.listTarget.innerHTML = '';
    this.emptyTarget.classList.add('hidden');
  }

  showEmpty() {
    this.loadingTarget.classList.add('hidden');
    this.listTarget.innerHTML = '';
    this.emptyTarget.classList.remove('hidden');
  }

  showError(message) {
    this.loadingTarget.classList.add('hidden');
    this.listTarget.innerHTML = `<li class="error">${message}</li>`;
  }
});

app.register('search-pagination', class extends Controller {
  render(total, page) {
    this.element.innerHTML = this.buildPagination(total, page);
  }

  buildPagination(total, page) {
    // ... pagination HTML generation
  }
});

Common Mistakes

1. Forgetting the Outlet Attribute on the Child

<!-- ❌ Missing data-parent-outlet="child" -->
<div data-controller="child">

Without the outlet attribute, the parent controller cannot find the child. The outlet attribute must be on the child element.

2. Accessing an Outlet Before It Exists

// ❌ Assumes outlet is always present
this.childOutlet.action();

// ✅ Check first
if (this.hasChildOutlet) {
  this.childOutlet.action();
}

3. Circular Outlet Dependencies

// ❌ A references B, B references A -- circular!
app.register('a', class extends Controller { static outlets = ['b']; });
app.register('b', class extends Controller { static outlets = ['a']; });

Stimulus handles this, but it can lead to confusing initialization order. Use a clear parent-child hierarchy instead.

4. Trying to Access Targets of an Outlet Directly

// ❌ Cannot access outlet's targets directly
this.resultsOutlet.listTarget // Error!

// ✅ The outlet exposes its own API
this.resultsOutlet.render(data)

5. Using Outlets for Simple Data Passing

// Overkill: use values or events instead
// Outlets are for calling methods and accessing controller state

Practice Questions

1. How do you declare an outlet in a controller?

static outlets = ['results']. This enables this.resultsOutlet, this.resultsOutlets, and this.hasResultsOutlet.

2. What is the purpose of the outlet attribute in HTML?

data-search-outlet="results" marks an element as the results outlet for the search controller, enabling the search controller to reference the results controller.

3. When does nameOutletConnected() fire?

When a child element with the outlet attribute and matching controller enters the DOM.

4. How do you handle optional outlets?

Use this.hasNameOutlet to check if the outlet exists before accessing this.nameOutlet.

Challenge

Build a tab-container controller with tab outlets. Each tab outlet should be a separate controller instance. The tab container should activate/deactivate tab controllers and coordinate their visibility.

FAQ

### Can an element be an outlet for multiple controllers?

Yes. One element can be an outlet for several parent controllers: data-controller="child" data-parent1-outlet="child" data-parent2-outlet="child".

Can I use outlets with nested controllers?

Yes. Outlets work across any DOM hierarchy. The child element just needs to have both the child controller and the outlet attribute.

How do outlets compare to events for cross-controller communication?

Outlets are direct references (tight coupling). Events are decoupled (broadcast/subscribe). Use outlets for well-known parent-child relationships. Use events for loosely coupled components.

Can I access outlet properties other than the controller reference?

The outlet property (this.resultsOutlet) returns the controller instance. From there you can access its targets, values, classes, and methods.

What's Next

Topic Description
{{< ref "stimulus-loading" >}} Lazy loading controllers and integrating with Turbo
{{< ref "stimulus-typescript" >}} Typing controllers, targets, values, and outlets with TypeScript
{{< ref "stimulus-testing" >}} Testing Stimulus controllers with Jest and DOM testing
JavaScript Patterns Communication patterns: pub/sub, Mediator, Observer

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This outlets tutorial powers the coordinated search, filter, and pagination system in the Doda Browser extension.

What's Next

Congratulations on completing this Stimulus Outlets 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