Skip to content
Stimulus Lifecycle & State Management Explained — Complete Guide

Stimulus Lifecycle & State Management Explained — Complete Guide

DodaTech Updated Jun 6, 2026 10 min read

Stimulus lifecycle callbacks (initialize, connect, disconnect) control when your controller code runs — essential for proper setup, cleanup, and state management.

What You’ll Learn

You’ll understand the three lifecycle callbacks, when each runs, how to manage state using values and private fields, and how to avoid memory leaks by cleaning up in disconnect.

Why the Lifecycle Matters

Every Stimulus controller goes through a predictable lifecycle — from creation to DOM attachment to removal. If you set up event listeners in connect() but forget to remove them in disconnect(), those listeners keep running even after the element is gone. This is a common source of bugs and memory leaks.

Security note: Understanding Stimulus Lifecycle State helps build more secure applications — a core principle at DodaTech, where tools like Durga Antivirus Pro and Doda Browser rely on solid implementation practices.

In the Doda Browser extension, lifecycle management ensures that when a user closes a browser tab, all associated Stimulus controllers clean up their timers, network requests, and DOM observers. This keeps the extension responsive and prevents ghost processes.

    flowchart LR
  A["Controller Instantiated"] --> B["initialize()"]
  B --> C["Element added to DOM"]
  C --> D["connect()"]
  D --> E["Element removed from DOM"]
  E --> F["disconnect()"]
  F --> G["Element re-added"]
  G --> D
  style A fill:#4f46e5,color:#fff
  style D fill:#059669,color:#fff
  style F fill:#dc2626,color:#fff
  
Prerequisites: This guide builds on https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-getting-started/. You should know how to create a controller, register it, and use targets and actions. Basic JavaScript class syntax (constructor, private fields) is helpful.

The Three Lifecycle Callbacks

Think of a controller like a light bulb. initialize() is when the bulb is manufactured. connect() is when you screw it in and it lights up. disconnect() is when you unscrew it.

initialize()

Called once when the controller instance is first created — before the element is in the DOM.

class extends Stimulus.Controller {
  initialize() {
    this.defaultCount = 0
    this.items = []
    // ❌ Don't access targets here — they don't exist yet!
    // this.nameTarget.value = 'hello' // Error!
  }
}

Why use initialize? For setting up data structures, default values, and bound methods that don’t depend on the DOM. It’s like preparing ingredients before you start cooking.

connect()

Called when the controller’s element is added to the DOM. This is where the magic happens — targets, values, and classes are all available.

class extends Stimulus.Controller {
  static targets = ['display']
  static values = { count: Number }

  connect() {
    // DOM is ready — safe to access everything
    this.displayTarget.textContent = this.countValue
    this.setupEventListeners()
  }

  setupEventListeners() {
    this._boundResize = this.handleResize.bind(this)
    window.addEventListener('resize', this._boundResize)
  }

  handleResize() {
    console.log('Window resized:', window.innerWidth)
  }
}

Why use connect? For anything that requires the DOM — reading target values, setting up timers, fetching initial data, adding global event listeners. Think of it as your “setup” phase.

disconnect()

Called when the controller’s element is removed from the DOM. Always clean up what you set up.

class extends Stimulus.Controller {
  disconnect() {
    // Remove event listeners
    window.removeEventListener('resize', this._boundResize)

    // Clear timers
    clearInterval(this._timer)

    // Cancel network requests
    if (this._abortController) {
      this._abortController.abort()
    }
  }
}

Why disconnect matters: If you don’t clean up, event listeners accumulate. Every time a user navigates away and comes back, a new listener is added. The old ones never die. This is a leading cause of “my app gets slower over time” bugs.

Reconnecting Behavior

When an element is removed and re-added to the DOM, disconnect() and connect() run in sequence. Properties on the controller instance persist between reconnections:

class extends Stimulus.Controller {
  connect() {
    this.count = (this.count || 0) + 1
    console.log(`Connected ${this.count} times`)
  }
}

This is useful for knowing how many times a component has been reconnected (e.g., a tab panel that toggles visibility).

Complete Lifecycle Demo

application.register('lifecycle-demo', class extends Stimulus.Controller {
  static targets = ['log']

  initialize() {
    this.log('initialize — controller created')
    this.connections = 0
  }

  connect() {
    this.connections++
    this.log(`connect (${this.connections}) — DOM ready`)
  }

  disconnect() {
    this.log('disconnect — cleaning up')
  }

  log(message) {
    const entry = document.createElement('div')
    entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
    this.logTarget.appendChild(entry)
  }
})

Expected log output:

[12:00:00] initialize — controller created
[12:00:00] connect (1) — DOM ready
[12:00:05] disconnect — cleaning up
[12:00:06] connect (2) — DOM ready

State Management with the Values API

The Values API is Stimulus’s built-in state management. It connects HTML attributes to JavaScript properties, with change detection built in.

Declaring Values

class extends Stimulus.Controller {
  static values = {
    // Shorthand (type only)
    count: Number,
    name: String,
    active: Boolean,

    // Full syntax (type + default)
    delay: { type: Number, default: 300 },
    items: { type: Array, default: () => [] },
    config: { type: Object, default: () => ({ theme: 'light' }) }
  }
}

Why two syntaxes? The shorthand is convenient. The full syntax lets you set defaults — and for arrays/objects, you must use the factory function () => [] so each controller gets its own instance.

Value Change Callbacks

When you set a value in JavaScript (this.countValue = 5), Stimulus automatically calls countValueChanged(current, previous):

class extends Stimulus.Controller {
  static values = { count: Number }

  countValueChanged(current, previous) {
    console.log(`Changed from ${previous} to ${current}`)
    this.updateDisplay()
  }

  increment() {
    this.countValue++  // Triggers countValueChanged
  }
}

Why this matters: You don’t have to manually watch for changes. Stimulus does it for you. In the Doda Browser extension, value change callbacks drive real-time UI updates — when a settings value changes, the corresponding panel updates automatically.

Mutating Arrays and Objects

Arrays and objects are tricky. If you push to an array, Stimulus doesn’t detect the change:

// ❌ Won't trigger change callback
this.itemsValue.push(newItem)

// ✅ Reassign to trigger change
this.itemsValue = [...this.itemsValue, newItem]

// ✅ Also works
this.itemsValue = this.itemsValue.concat(newItem)

Why? Stimulus checks if the reference changed. push() modifies the existing array in place. Spread creates a new array with a new reference.

State Management with the Classes API

The Classes API bridges controllers and CSS. It lets you define CSS class names in HTML and apply them in JavaScript.

class extends Stimulus.Controller {
  static classes = ['active', 'disabled', 'highlight']
}
<div data-controller="tabs"
     data-tabs-active-class="tab-active"
     data-tabs-disabled-class="tab-disabled">
activate() {
  this.element.classList.add(this.activeClass)    // "tab-active"
  this.element.classList.remove(this.disabledClass) // "tab-disabled"
}

Why the Classes API instead of hardcoding? It makes your controllers theme-agnostic. The same controller can use Tailwind classes (bg-blue-500) in one project and custom CSS classes (is-active) in another — just by changing the HTML attribute.

Private State with Class Fields

For state that shouldn’t be exposed in HTML, use JavaScript private fields:

class extends Stimulus.Controller {
  #cache = new Map()
  #clickCount = 0

  connect() {
    this.#cache.set('started', Date.now())
  }

  recordClick() {
    this.#clickCount++
  }

  get elapsed() {
    return Date.now() - (this.#cache.get('started') || Date.now())
  }
}

Why private fields? They’re truly private — no HTML attribute can access or modify them. Use them for internal state like caching, debounce timers, and connection tracking.

Common Mistakes

1. Not cleaning up in disconnect

// ❌ Memory leak: listener persists after element removal
connect() {
  window.addEventListener('scroll', this.handleScroll)
}
disconnect() { }  // Nothing!

// ✅ Always clean up
connect() {
  this._boundScroll = this.handleScroll.bind(this)
  window.addEventListener('scroll', this._boundScroll)
}
disconnect() {
  window.removeEventListener('scroll', this._boundScroll)
}

2. Mutating array/object values instead of replacing

// ❌ No callback triggered
this.itemsValue.push('new')

// ✅ Creates new reference — callback fires
this.itemsValue = [...this.itemsValue, 'new']

3. Accessing targets in initialize()

// ❌ initialize runs before DOM is ready
initialize() {
  this.nameTarget.value = 'Default'  // TypeError!
}

// ✅ Set up non-DOM state in initialize
initialize() {
  this.defaultName = 'Default'
}
connect() {
  this.nameTarget.value = this.defaultName
}

4. Forgetting that values don’t observe HTML changes after connect

If you change data-controller-count-value="10" in the HTML after connect(), Stimulus won’t pick it up. Values are read once on connect.

5. Setting values without change callbacks for derived state

static values = { count: Number, doubled: Number }

// ❌ Have to manually update doubled each time
increment() {
  this.countValue++
  this.doubledValue = this.countValue * 2
}

// ✅ Use value change callback
countValueChanged() {
  this.doubledValue = this.countValue * 2
}
increment() {
  this.countValue++  // Automatically updates doubled
}

6. Overusing values for private state

// ❌ Exposes internal state in HTML for no reason
static values = { internalTimer: Number }

// ✅ Keep it private
#timerId = null

Practice Questions

1. What is the order of lifecycle callbacks?

initialize()connect()disconnect(). If the element is re-added, connect() runs again. initialize() runs only once.

2. Why should you use disconnect()?

To prevent memory leaks. Remove event listeners, clear timers, cancel fetch requests. If you set it up in connect(), tear it down in disconnect().

3. How do you detect when a value changes?

Define a {name}ValueChanged callback. Stimulus calls it with (current, previous) arguments whenever the value changes.

4. What’s the difference between initialize() and connect()?

initialize() runs once, before DOM attachment — safe for data setup only. connect() runs every time the element enters the DOM — safe for DOM access.

🏆 Challenge

Build a controller that fetches data from an API on connect and cancels the request on disconnect. Use an AbortController and store it as a private field. Bonus: show a loading state using the Classes API.

FAQ

How do I share state between controllers?

Use values for HTML-driven shared state. For more complex sharing, use custom events (covered in https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-communication/). Avoid global variables — they break encapsulation.

Do values support two-way binding?

Not exactly. Values flow from HTML to JS on connect, and from JS to HTML on change. For true two-way reactive binding, consider Alpine.js which has x-model.

Can I use async/await in lifecycle callbacks?

Yes, but connect() returns void, so you can’t await it externally. Use async connect() for fire-and-forget operations like data fetching, but handle errors internally with try/catch.

What happens to values if the HTML attribute changes after connect?

Stimulus doesn’t observe attribute changes after connect by default. If you need this, use a MutationObserver inside connect() and clean it up in disconnect().

Try It Yourself

A complete lifecycle demo with a theme switcher that persists to localStorage:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Stimulus Lifecycle Demo</title>
  <script defer src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.x.x/dist/stimulus.umd.min.js"></script>
</head>
<body style="font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem;">
  <h1>Lifecycle Demo</h1>

  <div data-controller="theme-switcher"
       data-theme-switcher-theme-value="light">
    <p>Current theme: <strong data-theme-switcher-target="label">light</strong></p>
    <button data-action="click->theme-switcher#toggle">Toggle Theme</button>
    <div data-theme-switcher-target="log"
         style="margin-top: 1rem; font-family: monospace; font-size: 0.85rem;"></div>
  </div>

  <script>
    (() => {
      const app = Stimulus.Application.start()

      app.register('theme-switcher', class extends Stimulus.Controller {
        static targets = ['label', 'log']
        static values = { theme: { type: String, default: 'light' } }

        initialize() {
          this.log('initialize() — controller created')
          this.theme = localStorage.getItem('theme') || 'light'
        }

        connect() {
          this.log('connect() — DOM ready')
          this.themeValue = this.theme
        }

        disconnect() {
          this.log('disconnect() — cleaning up')
        }

        themeValueChanged(current) {
          this.labelTarget.textContent = current
          document.body.style.background = current === 'dark' ? '#1a1a2e' : '#fff'
          document.body.style.color = current === 'dark' ? '#e0e0e0' : '#000'
          localStorage.setItem('theme', current)
          this.log(`themeValueChanged: ${current}`)
        }

        toggle() {
          this.themeValue = this.themeValue === 'light' ? 'dark' : 'light'
        }

        log(msg) {
          const el = document.createElement('div')
          el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`
          this.logTarget.appendChild(el)
        }
      })
    })()
  </script>
</body>
</html>

Try this: Toggle the theme and watch the log. Notice initialize() runs once, but connect() and themeValueChanged() run on every toggle. Refresh the page — the theme persists from localStorage.

What’s Next

TopicDescription
https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-communication/Cross-controller communication with events and outlets
https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-patterns/Real-world patterns: modals, autocomplete, infinite scroll
https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-getting-started/Review controllers, targets, and actions fundamentals
Modern JavaScriptPrivate class fields and async patterns used in controllers
REST APIFetching data in connect() with the REST API pattern

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. The lifecycle patterns in this tutorial power real-time theme switching, tab persistence, and connection state tracking in the Doda Browser extension.

What’s Next

Congratulations on completing this Stimulus Lifecycle State 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