Stimulus Outlets — Complete Guide with Examples
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}OutletConnectedand{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
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