connect(): where a controller's real work begins
A Stimulus controller class exists as soon as its file loads: every method is defined, every static declaration is read. That doesn't mean the controller is doing anything yet. connect() is the method Stimulus calls when an element carrying this controller's identifier shows up in the DOM. This app's chart controller uses that moment for the one thing that couldn't happen earlier.
A controller that can't start until connect()
app/javascript/controllers/ui/chart_controller.js, its connect() and render():
export default class extends Controller {
static values = { type: { type: String, default: "bar" }, data: Object, options: Object }
static targets = ["canvas"]
connect() {
this.disconnected = false
this.render()
}
async render() {
const { default: Chart } = await import("chart.js/auto")
// The element can be gone by the time the import resolves.
if (this.disconnected || !this.hasCanvasTarget) return
const ctx = this.canvasTarget
this.chart = new Chart(ctx, { type: this.typeValue, data: this.dataValue, options: { ...this.optionsValue } })
}
}
connect() calls this.render(), which reads this.canvasTarget, the <canvas> element this controller is attached to. connect() itself only runs once that element exists, as the opening said. But render() is async. It imports Chart.js dynamically, a deliberate choice: the library is about 180 KB of JavaScript, pinned with preload: false, and only worth loading on a page that draws a chart. That import can take long enough for the element to disconnect before it resolves. The guard, if (this.disconnected || !this.hasCanvasTarget) return, exists because connect()'s guarantee covers the instant it runs, not whatever runs after an await.
Where this app tracks the answer
this.disconnected = false, set at the top of connect(), and its mirror in disconnect() (next lesson) are this controller's own way of answering "am I still here?" after an await. Stimulus doesn't check that for you. this.hasCanvasTarget is a Stimulus-generated helper (one exists for every declared target) that turns false once the target element is gone. It's the second half of the same guard.
What triggers connect()
Stimulus watches the DOM with a MutationObserver. It isn't polling, running on a timer or tied to DOMContentLoaded. When an element with a matching data-controller="chart" attribute appears anywhere in the document, Stimulus creates the controller and calls connect() on it. The element can come from a fresh page load, a Turbo Frame swap or a turbo_stream.append (two courses ago). A controller connected through a Turbo Stream update gets the same connect() call as one present since the first page load. At that instant, the element existing is the only thing that matters.
Why this is more than another method
Code that reads a target, a value or anything else about the controller's element has to run inside connect() or something connect() calls. It can't go in the class body or a top-level statement, because nothing about the element is guaranteed to exist until then. This app's chart_controller.js never reads this.canvasTarget outside render(). And render() is only called from connect() and, as later lessons show, from value-change callbacks that only run once a chart exists. Even there it checks that the element is still present before touching it, because its own await means time has passed since connect() last confirmed that.
Key takeaways
connect()runs when Stimulus detects an element with this controller'sdata-controlleridentifier in the DOM, not when the file loads or the class is defined.- This app's
chart_controller.jscallsthis.render()fromconnect(), butrender()isasync. It imports Chart.js dynamically, and by the time the import resolves the element could be gone. - The guard
if (this.disconnected || !this.hasCanvasTarget) returntracks the controller's connection state across thatawait.connect()'s guarantee covers the instant it runs, not what runs after an awaited promise resolves. - Stimulus finds connecting elements with a
MutationObserver, not polling or a page-load event. A fresh page load, a Turbo Frame swap and a Turbo Stream append all triggerconnect()the same way.
Keep going with lesson 2
disconnect(): the mirror that undoes connect()
disconnect() undoes what connect() started, destroying a Chart.js instance or clearing the flash controller's pending setTimeout.
A free account also gives you the quizzes, your progress and notes, and the discussion.