connect(): where a controller's real work begins

Free preview 4 min to read

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's data-controller identifier in the DOM, not when the file loads or the class is defined.
  • This app's chart_controller.js calls this.render() from connect(), but render() is async. It imports Chart.js dynamically, and by the time the import resolves the element could be gone.
  • The guard if (this.disconnected || !this.hasCanvasTarget) return tracks the controller's connection state across that await. 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 trigger connect() the same way.
Lesson 1 of 7 · free

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.

Type to start searching...