Stimulus Controllers — Complete Guide with Examples
In this tutorial, you'll learn about Stimulus Controllers. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Stimulus controllers are JavaScript classes that add interactive behavior to HTML through lifecycle callbacks, targets, and actions without requiring a build step.
What You'll Learn
- Creating and registering controllers with the Stimulus application
- Using lifecycle callbacks:
initialize,connect,disconnect - Nesting controllers and controlling scope
- Using multiple controllers on a single element
- Structuring controller code for maintainability
- Practical patterns for real-world applications
Why It Matters
Controllers are the foundation of every Stimulus application. Understanding how to create, register, and compose controllers determines how maintainable and scalable your interactive components become. Proper controller structure prevents memory leaks, ensures clean DOM management, and makes your code predictable. In the Doda Browser extension, controllers power interactive settings panels, tab management interfaces, and bookmark organizers with clean Separation Of Concerns.
Learning Path
flowchart LR A[Getting
Started] --> B[Controllers] B --> C[Targets] C --> D[Actions] B --> E[Lifecycle &
State] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style E fill:#059669,color:#fff
Creating and Registering Controllers
Every Stimulus controller is a class that extends Stimulus.Controller. You register it with an identifier so Stimulus knows how to connect it to HTML.
import { Application, Controller } from '@hotwired/stimulus';
const app = Application.start();
app.register('hello', class extends Controller {
connect() {
this.element.textContent = 'Hello from Stimulus!';
}
});
<div data-controller="hello"></div>
Teacher explains: The identifier 'hello' maps to data-controller="hello" in HTML. When Stimulus encounters that attribute, it creates a controller instance and calls connect(). The this.element property refers to the DOM element that has data-controller="hello".
Controller Naming Conventions
Identifiers use kebab-case in HTML and camelCase in JavaScript:
app.register('file-upload', class extends Controller {
// ...
});
<div data-controller="file-upload"></div>
Lifecycle Callbacks
Stimulus controllers have three lifecycle callbacks that fire automatically.
initialize() — Once per Controller Instance
app.register('counter', class extends Controller {
static targets = ['display'];
initialize() {
this.count = 0;
console.log('Initialize: controller instance created');
}
connect() {
console.log('Connect: DOM element is available');
this.displayTarget.textContent = this.count;
}
disconnect() {
console.log('Disconnect: DOM element was removed');
}
increment() {
this.count++;
this.displayTarget.textContent = this.count;
}
});
<div data-controller="counter">
<span data-counter-target="display">0</span>
<button data-action="click->counter#increment">+</button>
</div>
Teacher explains: initialize() runs when the controller is instantiated, before the DOM is available. Use it for setting up initial state. connect() runs when the DOM element is attached to the page. Use it for DOM-dependent setup. disconnect() runs when the element is removed. Use it for cleanup.
Lifecycle Timing
initialize() {
// DOM not available yet
// this.element exists but is not in the document
// Good for: setting defaults, binding methods
}
connect() {
// DOM is available and element is in the document
// Good for: reading element attributes, setting up timers
}
disconnect() {
// Element is being removed from the document
// Good for: clearing timers, removing event listeners
}
Nesting Controllers
Controllers can be nested, and child controllers do not inherit from parent controllers. Each is independent.
<div data-controller="form">
<input type="text" data-form-target="nameInput">
<div data-controller="autocomplete">
<ul data-autocomplete-target="results"></ul>
</div>
<button data-action="click->form#submit">Submit</button>
</div>
app.register('form', class extends Controller {
static targets = ['nameInput'];
submit() {
const name = this.nameInput.value;
console.log('Form submitted with:', name);
}
});
app.register('autocomplete', class extends Controller {
static targets = ['results'];
connect() {
console.log('Autocomplete controller ready');
}
});
Teacher explains: The form controller cannot access the autocomplete controller's targets directly (unless using outlets). Each controller has its own scope. This isolation prevents naming conflicts and keeps components independent.
Multiple Controllers on One Element
Sometimes you want to compose behaviors from multiple controllers on the same element.
<div data-controller="tooltip dropdown">
<button data-action="click->dropdown#toggle">
Settings
</button>
<div data-dropdown-target="menu" class="hidden">
<a href="/profile">Profile</a>
<a href="/logout">Logout</a>
</div>
</div>
app.register('tooltip', class extends Controller {
connect() {
this.element.title = 'Click for options';
}
});
app.register('dropdown', class extends Controller {
static targets = ['menu'];
toggle() {
this.menuTarget.classList.toggle('hidden');
}
});
Teacher explains: Both controllers operate independently on the same element. The tooltip controller adds a title attribute, while the dropdown controller manages visibility. This composition pattern is cleaner than creating a single controller that does both.
Controller Ordering
Controllers execute in the order they appear in the data-controller attribute:
<div data-controller="tooltip dropdown">
<!-- tooltip connect() runs first, then dropdown connect() -->
</div>
Structuring Controllers for Maintainability
Use Private Fields for Internal State
app.register('timer', class extends Controller {
static targets = ['display'];
static values = { interval: { type: Number, default: 1000 } };
#intervalId = null;
connect() {
this.displayTarget.textContent = '0';
}
disconnect() {
this.stop();
}
start() {
if (this.#intervalId) return;
let count = 0;
this.#intervalId = setInterval(() => {
this.displayTarget.textContent = ++count;
}, this.intervalValue);
}
stop() {
if (this.#intervalId) {
clearInterval(this.#intervalId);
this.#intervalId = null;
}
}
});
Extract Helper Methods
app.register('search', class extends Controller {
static targets = ['input', 'results', 'empty'];
static values = { minLength: { type: Number, default: 3 } };
async search() {
const query = this.inputTarget.value.trim();
if (!this.#isValidQuery(query)) {
this.#showEmpty();
return;
}
try {
const results = await this.#fetchResults(query);
this.#renderResults(results);
} catch (error) {
this.#showError(error);
}
}
#isValidQuery(query) {
return query.length >= this.minLengthValue;
}
async #fetchResults(query) {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Search failed');
return response.json();
}
#renderResults(results) {
this.resultsTarget.innerHTML = results
.map(r => `<li>${r.name}</li>`)
.join('');
this.#toggleEmpty(results.length === 0);
}
#showEmpty() {
this.resultsTarget.innerHTML = '';
this.emptyTarget.classList.remove('hidden');
}
#showError(error) {
this.resultsTarget.innerHTML = `<li class="error">${error.message}</li>`;
}
#toggleEmpty(isEmpty) {
this.emptyTarget.classList.toggle('hidden', !isEmpty);
}
});
Real-world: Settings Panel Controller
<div data-controller="settings-panel"
data-settings-panel-endpoint-value="/api/settings"
data-settings-panel-auto-save-value="true">
<h2 data-settings-panel-target="title">Settings</h2>
<div class="setting-row">
<label>Notifications</label>
<input type="checkbox" data-settings-panel-target="notificationToggle"
data-action="change->settings-panel#toggleNotification">
</div>
<div class="setting-row">
<label>Theme</label>
<select data-settings-panel-target="themeSelect"
data-action="change->settings-panel#changeTheme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<button data-action="click->settings-panel#saveAll"
data-settings-panel-target="saveButton">
Save All
</button>
<p data-settings-panel-target="status"></p>
</div>
app.register('settings-panel', class extends Controller {
static targets = ['title', 'notificationToggle', 'themeSelect', 'saveButton', 'status'];
static values = { endpoint: String, autoSave: Boolean };
connect() {
this.loadSettings();
}
async loadSettings() {
try {
const response = await fetch(this.endpointValue);
const settings = await response.json();
this.notificationToggleTarget.checked = settings.notifications;
this.themeSelectTarget.value = settings.theme;
this.#showStatus('Settings loaded');
} catch (error) {
this.#showStatus('Failed to load settings', 'error');
}
}
toggleNotification() {
if (this.autoSaveValue) {
this.saveSetting('notifications', this.notificationToggleTarget.checked);
}
}
changeTheme() {
if (this.autoSaveValue) {
this.saveSetting('theme', this.themeSelectTarget.value);
}
}
async saveAll() {
this.saveButtonTarget.disabled = true;
try {
await this.saveSetting('notifications', this.notificationToggleTarget.checked);
await this.saveSetting('theme', this.themeSelectTarget.value);
this.#showStatus('All settings saved');
} finally {
this.saveButtonTarget.disabled = false;
}
}
async saveSetting(key, value) {
const response = await fetch(this.endpointValue, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value })
});
if (!response.ok) throw new Error(`Failed to save ${key}`);
}
#showStatus(message, type = 'info') {
this.statusTarget.textContent = message;
this.statusTarget.className = `status-${type}`;
if (type === 'info') {
setTimeout(() => { this.statusTarget.textContent = ''; }, 3000);
}
}
});
Common Mistakes
1. Forgetting to Register the Controller
// ❌ Class exists but Stimulus doesn't know about it
class HelloController extends Controller { }
// ✅ Register it
app.register('hello', HelloController);
2. Accessing DOM in initialize()
// ❌ DOM is not ready in initialize
initialize() {
this.element.textContent = 'Ready'; // Error: element not in DOM
}
// ✅ Use connect() for DOM access
connect() {
this.element.textContent = 'Ready';
}
3. Using the Wrong Identifier in HTML
// JavaScript: app.register('file-upload', ...)
// ❌ Wrong HTML identifier
<div data-controller="fileUpload"> // kebab-case needed!
// ✅ Correct
<div data-controller="file-upload">
4. Not Cleaning Up in disconnect()
// ❌ Timer keeps running after element is removed
connect() {
this._timer = setInterval(() => {}, 1000);
}
// ✅ Clean up
disconnect() {
clearInterval(this._timer);
}
5. Putting Too Much Logic in One Controller
// ❌ God controller that does everything
app.register('mega', class extends Controller {
static targets = ['form', 'list', 'modal', 'chart', 'map', 'search', 'filter', 'pagination'];
// 20+ methods
});
// ✅ Split into focused controllers
Practice Questions
1. What is the difference between initialize() and connect()?
initialize() runs when the controller instance is created (before DOM is available). connect() runs when the DOM element is attached to the document. Use initialize() for state setup, connect() for DOM-dependent work.
2. Can you have multiple controllers on the same element?
Yes, separate them with spaces: data-controller="tooltip dropdown". Each controller operates independently.
3. How do you access the controller's DOM element?
Use this.element inside any controller method. It refers to the element that has the data-controller attribute.
4. What happens if a controller identifier doesn't match any registration?
Stimulus ignores the unrecognized controller. The element will not have any controller behavior, and no error is thrown for that specific controller.
Challenge
Build a tabs controller that manages tab switching. It should have targets for tab buttons and tab panels, and switch the active tab when a button is clicked. Use private fields for the active tab index.
FAQ
What's Next
| Topic | Description |
|---|---|
| {{< ref "stimulus-targets" >}} | Deep dive into targets: naming, scoping, plural targets, and change callbacks |
| {{< ref "stimulus-actions" >}} | Master action descriptors, event options, keyboard events, and global events |
| Stimulus Getting Started | Review fundamentals if needed |
| JavaScript Classes | Review ES6 classes and private fields |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This controller pattern powers the interactive settings panels in the Doda Browser extension.
What's Next
Congratulations on completing this Stimulus Controllers 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