Skip to content

Ember Component Lifecycle — Constructor to Destruction

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Ember Component Lifecycle. We cover key concepts, practical examples, and best practices to help you master this topic.

Ember components have a defined lifecycle from creation to destruction. The constructor initializes state, getters compute derived data, modifiers handle DOM updates, and willDestroy cleans up resources. Understanding the lifecycle prevents memory leaks and ensures predictable rendering.

What You'll Learn

You will learn the Glimmer component lifecycle stages, how to use modifiers for DOM access, how to respond to argument changes, and how to clean up resources properly.

Why It Matters

Incorrect lifecycle management causes memory leaks, stale data, and unexpected behavior. A component that fetches data on creation but never cancels the fetch keeps running after destruction.

Real-World Use

A real-time dashboard component subscribes to a Websocket in constructor, updates tracked properties on each message, and unsubscribes in willDestroy. This prevents WebSocket connections from leaking when users navigate away.

flowchart LR
    A[Constructor] --> B[Getters computed]
    B --> C[did-insert modifier]
    C --> D[DOM available]
    D --> E[did-update modifier]
    E --> D
    D --> F[willDestroy]
    F --> G[Cleanup complete]

Lifecycle Stage 1: Constructor

The constructor runs when the component is created. Initialize tracked properties and set up non-DOM resources.

// app/components/timer.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class TimerComponent extends Component {
  @tracked elapsed = 0;
  @tracked isRunning = false;

  constructor(owner, args) {
    super(owner, args);
    console.log('Timer component created');
    this.startTime = Date.now();
    this.isRunning = true;
  }
}

Lifecycle Stage 2: Getters and Tracked Properties

Getters recompute whenever tracked properties they depend on change. They run before the first render and after every tracked change.

// app/components/price-display.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class PriceDisplayComponent extends Component {
  @tracked taxRate = 0.1;
  @tracked discount = 0;

  get basePrice() {
    return this.args.product?.price || 0;
  }

  get taxAmount() {
    return this.basePrice * this.taxRate;
  }

  get discountAmount() {
    return this.basePrice * (this.discount / 100);
  }

  get totalPrice() {
    return this.basePrice + this.taxAmount - this.discountAmount;
  }

  get formattedTotal() {
    return `$${this.totalPrice.toFixed(2)}`;
  }
}

Lifecycle Stage 3: did-insert Modifier

The {{did-insert}} modifier runs after the component element is inserted into the DOM. Use it for DOM measurements, third-party library integration, and subscribing to external events.

// app/components/chart.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ChartComponent extends Component {
  @tracked chartInstance = null;

  @action
  initializeChart(element) {
    console.log('Chart element mounted:', element);
    // Third-party chart library integration
    this.chartInstance = new ChartLib(element, {
      type: this.args.type || 'bar',
      data: this.args.data,
      options: { responsive: true }
    });
  }

  @action
  updateChart(element) {
    if (this.chartInstance) {
      this.chartInstance.data = this.args.data;
      this.chartInstance.update();
    }
  }
}
{{! app/components/chart.hbs }}
<div
  class="chart-container"
  {{did-insert this.initializeChart}}
  {{did-update this.updateChart @data}}
>
  <canvas></canvas>
</div>

Lifecycle Stage 4: did-update Modifier

The {{did-update}} modifier runs when specified arguments change. It is used to respond to data changes after initial render.

// app/components/search-results.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';

export default class SearchResultsComponent extends Component {
  @service store;
  @tracked results = [];
  @tracked isLoading = false;

  @action
  async loadResults(element, [query]) {
    if (!query || query.length < 3) {
      this.results = [];
      return;
    }

    this.isLoading = true;
    try {
      this.results = await this.store.query('post', { q: query });
    } catch (error) {
      console.error('Search failed:', error);
      this.results = [];
    } finally {
      this.isLoading = false;
    }
  }
}
{{! app/components/search-results.hbs }}
{{#if this.isLoading}}
  <div class="spinner">Searching...</div>
{{else if this.results.length}}
  <ul class="results" {{did-update this.loadResults @query}}>
    {{#each this.results as |result|}}
      <li>{{result.title}}</li>
    {{/each}}
  </ul>
{{else if @query}}
  <p class="no-results">No results found for "{{@query}}"</p>
{{/if}}

Lifecycle Stage 5: willDestroy

The willDestroy method runs when the component is about to be removed. Clean up all resources: timers, subscriptions, DOM listeners, and third-party instances.

// app/components/live-feed.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class LiveFeedComponent extends Component {
  @service session;
  @tracked messages = [];
  @tracked connectionStatus = 'disconnected';

  constructor(owner, args) {
    super(owner, args);
    this.connect();
  }

  connect() {
    this.connectionStatus = 'connecting';
    this.ws = new WebSocket(this.args.url);

    this.ws.onopen = () => {
      this.connectionStatus = 'connected';
    };

    this.ws.onmessage = (event) => {
      let data = JSON.parse(event.data);
      this.messages = [...this.messages, data];
    };

    this.ws.onerror = () => {
      this.connectionStatus = 'error';
    };
  }

  willDestroy() {
    console.log('Cleaning up WebSocket connection');
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
    super.willDestroy();
  }
}

Modifier Lifecycle

Modifiers have their own lifecycle: did-insert, did-update, and will-destroy.

// app/modifiers/click-outside.js
import { modifier } from 'ember-modifier';

export default modifier((element, [handler]) => {
  let onClickOutside = (event) => {
    if (!element.contains(event.target)) {
      handler(event);
    }
  };

  document.addEventListener('click', onClickOutside);

  // Cleanup function — runs on willDestroy
  return () => {
    document.removeEventListener('click', onClickOutside);
  };
});
{{! Usage }}
<div {{click-outside this.closeDropdown}}>
  Dropdown content
</div>

Complete Lifecycle Example

// app/components/lifecycle-demo.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class LifecycleDemoComponent extends Component {
  @tracked mountTime = null;

  constructor(owner, args) {
    super(owner, args);
    console.log('1. Constructor');
  }

  get processedArg() {
    console.log('2. Getter running');
    return this.args.value ? `Processed: ${this.args.value}` : 'No value';
  }

  @action
  onInsert(element) {
    this.mountTime = Date.now();
    console.log('3. did-insert — DOM available');
    console.log('   Element:', element.tagName);
  }

  @action
  onUpdate(element, [value]) {
    console.log('4. did-update — value changed to:', value);
  }

  willDestroy() {
    console.log('5. willDestroy — cleaning up');
    super.willDestroy();
  }
}

Common Mistakes

  1. Not calling super.willDestroy(). Failing to call super breaks parent cleanup. Always call super.willDestroy() in your implementation.
  2. Setting up DOM listeners in constructor. The DOM does not exist in constructor. Use did-insert modifier for DOM access.
  3. Modifying tracked properties synchronously in getters. Getters should not have side effects. They should only compute and return values.
  4. Not cleaning up third-party library instances. Chart instances, map instances, and other third-party objects must be destroyed in willDestroy.
  5. Using did-update without specifying dependent keys. Without dependents, did-update runs on every render. Specify the exact arguments that trigger updates.

Practice Questions

  1. What lifecycle stage runs when a component is first inserted into the DOM?
  2. How do you react to changes in component arguments?
  3. What should you clean up in the willDestroy method?
  4. Why should you not access the DOM in the constructor?
  5. Challenge: Create a component that fetches data from an API when inserted, tracks loading state, supports refetch when arguments change, and cancels the fetch on destruction. Use AbortController for cancellation.

FAQ

What modifier runs after the component is inserted?

{{did-insert}} runs once after the element is inserted into the DOM.

How do I run code when an argument changes?

Use {{did-update}} and list the tracked arguments as parameters.

Can I have multiple did-insert modifiers?

Yes. Modifiers run in the order they are listed.

What happens if I forget super.willDestroy?

Parent class cleanup does not run, causing resource leaks.

Are getters cached in Ember?

Getters are not cached. They recompute on every access. Use @cached decorator for expensive computations.

Mini Project

Create a DataTable component with full lifecycle management. Constructor: initialize default sort/filter state. did-insert: measure column widths, add resize Observer. did-update: re-sort when sort arg changes, re-filter when filter arg changes. willDestroy: disconnect observers, clean up sort/filter state. Each lifecycle stage should log its action.

What's Next

Now that you understand the lifecycle, learn Ember Helpers for template transformations. Then explore Ember Data for the data layer.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro