Introduction
Stimulus controllers have a defined lifecycle tied to the DOM. When an element with data-controller appears in the page, Stimulus creates a controller instance and calls lifecycle callbacks. When the element is removed, the controller is torn down.
Key Concepts
connect(): Called when the controller element is inserted into the DOM.disconnect(): Called when the controller element is removed from the DOM.initialize(): Called once when the controller class is first instantiated (before connect).this.element: Reference to the DOM element the controller is attached to.
Real World Context
Understanding the lifecycle is critical for managing resources. You start timers, open WebSocket connections, or initialize third-party libraries in connect(), and clean them up in disconnect(). Without proper cleanup, you get memory leaks and ghost event listeners.
Deep Dive
The Lifecycle Sequence
1. Element with data-controller appears in DOM
2. Stimulus finds the matching controller class
3. initialize() — called once (first time only)
4. connect() — called every time the element enters the DOM
5. ... controller is active ...
6. disconnect() — called when the element leaves the DOM
With Turbo Drive, elements frequently enter and leave the DOM as pages change. A controller on the current page is connected, then disconnected when Turbo navigates away, then reconnected if the user returns via the back button.
Practical Example: Auto-Save
javascript// app/javascript/controllers/autosave_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.timer = setInterval(() => { this.element.requestSubmit() }, 30000) // Auto-save every 30 seconds } disconnect() { clearInterval(this.timer) } }
The connect() method starts a timer. The disconnect() method clears it. Without the cleanup in disconnect(), navigating away would leave the timer running, causing errors when it tries to submit a form that no longer exists.
Third-Party Library Integration
javascriptimport { Controller } from "@hotwired/stimulus" import flatpickr from "flatpickr" export default class extends Controller { connect() { this.picker = flatpickr(this.element, { dateFormat: "Y-m-d", enableTime: false }) } disconnect() { this.picker.destroy() } }
Initialize the library in connect() and destroy it in disconnect(). This pattern works for any JavaScript library: charts, maps, editors, date pickers.
Common Pitfalls
- Forgetting disconnect cleanup — Resources started in
connect()must be cleaned up indisconnect(). Timers, event listeners, and library instances all need explicit teardown. - Using initialize for DOM-dependent setup —
initialize()runs before the element may be fully in the DOM. Useconnect()for anything that accesses DOM elements.
Best Practices
- Always pair connect/disconnect — If you create something in
connect(), destroy it indisconnect(). - Use connect for third-party libs — Initialize external libraries in
connect()so they work correctly with Turbo navigation.
Summary
initialize()runs once,connect()runs each time the element enters the DOM,disconnect()runs when it leaves.- With Turbo, controllers connect and disconnect frequently as pages change.
- Always clean up resources (timers, listeners, libraries) in
disconnect(). - Use
connect()(notinitialize()) for DOM-dependent setup.
Code Examples
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize() {
// Runs once when controller class is first used
console.log("Controller class initialized")
}
connect() {
// Runs each time the element enters the DOM
console.log(`Connected to ${this.element.id}`)
this.startPolling()
}
disconnect() {
// Runs when element leaves DOM — clean up!
this.stopPolling()
}
startPolling() {
this.pollTimer = setInterval(() => this.refresh(), 5000)
}
stopPolling() {
clearInterval(this.pollTimer)
}
refresh() {
// Fetch fresh data
}
}