Skip to content
All essays
WebJanuary 15, 202612 min

Mastering Ember.js Components: Building Reusable UI Elements

Deep dive into Ember.js components, including Glimmer components, lifecycle hooks, arguments, and composition patterns

Ü
Ümit Uz
Mobile & Full Stack Developer

Components are the building blocks of modern Ember.js applications. Since Ember Octane, components are now Glimmer components—lighter, faster, and easier to use than ever before. This guide covers everything you need to know about creating powerful, reusable components.

Glimmer Components vs. Classic Components

Ember Octane introduced Glimmer components, which are the modern standard:

javascript
// Modern Glimmer Component
import Component from '@glimmer/component';

export default class UserProfile extends Component {
  // Component logic here
}

Glimmer components offer:

  • No internal state management - Use @tracked instead
  • No lifecycle hooks - Use {{did-insert}} and {{will-destroy}} modifiers
  • Better performance - No backing classes for template-only components
  • Simpler API - Less boilerplate code

Component Structure

Template-Only Components

The simplest components don't need JavaScript:

handlebars
{{! app/components/avatar.hbs }}
<div class="avatar" style={{this.style}}>
  <img src={{@src}} alt={{@alt}} class="avatar-image" />
</div>

JavaScript file is optional for simple cases.

JavaScript Components

For logic and computed properties:

javascript
// app/components/user-card.js
import Component from '@glimmer/component';

export default class UserCardComponent extends Component {
  get displayName() {
    const { firstName, lastName } = this.args;
    return `${firstName} ${lastName}`;
  }

  get isPremium() {
    return this.args.userTier === 'premium';
  }
}
handlebars
{{! app/components/user-card.hbs }}
<div class="user-card {{if this.isPremium 'premium'}}">
  <h2>{{this.displayName}}</h2>
  <p>{{@email}}</p>
  {{yield}}
</div>

Arguments (@args)

Arguments are passed to components using the @ prefix:

Passing Arguments

handlebars
<UserProfile
  @name="John Doe"
  @email="john@example.com"
  @role="admin"
/>

Receiving Arguments

javascript
// app/components/user-profile.js
import Component from '@glimmer/component';

export default class UserProfileComponent extends Component {
  get isAdmin() {
    return this.args.role === 'admin';
  }

  get displayEmail() {
    return this.args.email || 'No email provided';
  }
}

Argument Defaults

Use destructuring for defaults:

javascript
export default class ButtonComponent extends Component {
  constructor() {
    super(...arguments);
    this.args = {
      variant: 'primary',
      size: 'medium',
      ...this.args
    };
  }

  get classes() {
    return `btn btn-${this.args.variant} btn-${this.args.size}`;
  }
}

Blocks and Yielding

Components can yield content:

handlebars
{{! app/components/card.hbs }}
<div class="card">
  <header class="card-header">
    <h3>{{@title}}</h3>
  </header>

  <div class="card-body">
    {{yield}}
  </div>

  {{#if @hasFooter}}
    <footer class="card-footer">
      {{yield to="footer"}}
    </footer>
  {{/if}}
</div>

Usage:

handlebars
<Card @title="Welcome">
  <p>This is the main content.</p>

  <:footer>
    <button>Learn More</button>
  </:footer>
</Card>

Tracked Properties

Use @tracked for reactive state:

javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class CounterComponent extends Component {
  @tracked count = 0;

  get message() {
    return `Count: ${this.count}`;
  }

  @action
  increment() {
    this.count++;
  }

  @action
  decrement() {
    this.count--;
  }
}
handlebars
<div class="counter">
  <p>{{this.message}}</p>
  <button {{on "click" this.decrement}}>-</button>
  <button {{on "click" this.increment}}>+</button>
</div>

Tracked Objects and Arrays

For complex state, use tracked objects:

javascript
import { tracked } from '@glimmer/tracking';

class Todo {
  @tracked title;
  @tracked completed = false;

  constructor(title) {
    this.title = title;
  }

  toggle() {
    this.completed = !this.completed;
  }
}

export default class TodoListComponent extends Component {
  @tracked todos = [];

  @action
  addTodo(title) {
    this.todos = [...this.todos, new Todo(title)];
  }

  @action
  toggleTodo(todo) {
    todo.toggle();
    this.todos = [...this.todos]; // Trigger update
  }

  get completedCount() {
    return this.todos.filter(t => t.completed).length;
  }
}

Modifiers

Modifiers attach behavior to DOM elements:

Built-in Modifiers

handlebars
{{! Run code when element is inserted }}
<div {{did-insert this.setup}}>...</div>

{{! Run code when element will be destroyed }}
<div {{will-destroy this.cleanup}}>...</div>

{{! Attach event listeners }}
<button {{on "click" this.handleClick}}>Click</button>

{{! autofocus }}
<input {{autofocus}} />

{{! Run code when arguments change }}
<div {{did-update this.onDataChange @data}}>...</div>

Custom Modifiers

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

export default modifier(function clickOutside(element, [callback]) {
  const handleClick = (event) => {
    if (!element.contains(event.target)) {
      callback();
    }
  };

  document.addEventListener('click', handleClick);

  return () => {
    document.removeEventListener('click', handleClick);
  };
});

Usage:

handlebars
<div {{click-outside this.close}}>
  <p>Click outside to close</p>
</div>

Actions

Actions handle user interactions:

javascript
import { action } from '@ember/object';

export default class FormComponent extends Component {
  @tracked email = '';
  @tracked password = '';

  @action
  updateEmail(event) {
    this.email = event.target.value;
  }

  @action
  async handleSubmit(event) {
    event.preventDefault();

    try {
      await this.args.onSubmit({
        email: this.email,
        password: this.password
      });

      this.email = '';
      this.password = '';
    } catch (error) {
      console.error('Submission failed:', error);
    }
  }

  @action
  handleReset() {
    this.email = '';
    this.password = '';
  }
}
handlebars
<form {{on "submit" this.handleSubmit}}>
  <input
    type="email"
    value={{this.email}}
    {{on "input" this.updateEmail}}
  />

  <input
    type="password"
    value={{this.password}}
    {{on "input" (fn this.updateField "password")}}
  />

  <button type="submit">Submit</button>
  <button type="button" {{on "click" this.handleReset}}>Reset</button>
</form>

Component Composition

Build complex UIs from simple components:

handlebars
{{! app/components/data-table.hbs }}
<table class="data-table">
  <thead>
    <tr>
      {{#each @columns as |column|}}
        <th>{{column.label}}</th>
      {{/each}}
    </tr>
  </thead>

  <tbody>
    {{#each @data as |row|}}
      <tr>
        {{#each @columns as |column|}}
          <td>
            {{get row column.key}}
          </td>
        {{/each}}
      </tr>
    {{/each}}
  </tbody>
</table>

Usage:

handlebars
<DataTable
  @columns={{array
    (hash key="name" label="Name")
    (hash key="email" label="Email")
    (hash key="role" label="Role")
  }}
  @data={{this.users}}
/>

Contextual Components

Pass components as arguments:

javascript
// app/components/list.js
export default class ListComponent extends Component {
  get items() {
    return this.args.items || [];
  }
}
handlebars
{{! app/components/list.hbs }}
<ul class="list">
  {{#each this.items as |item|}}
    <li>
      {{#let (component @itemComponent item=item) as |Item|}}
        <Item />
      {{/let}}
    </li>
  {{/each}}
</ul>

Usage:

handlebars
<List
  @items={{this.users}}
  @itemComponent={{component "user-list-item"}}
/>

Error Boundaries

Handle component errors gracefully:

handlebars
{{#let (component "safe-component") as |Safe|}}
  <Safe>
    <RiskyComponent @data={{this.data}} />
  </Safe>
{{/let}}

Performance Optimization

Lazy Loading

javascript
// app/routes/index.js
import Route from '@ember/routing/route';

export default class IndexRoute extends Route {
  beforeModel() {
    // Lazy load heavy components
    import('app/components/heavy-component');
  }
}

Memoization

javascript
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';

export default class ExpensiveComponent extends Component {
  @tracked data;

  @cached
  get processedData() {
    // Expensive computation runs only when data changes
    return this.data.map(item => {
      return transform(item);
    });
  }
}

Testing Components

javascript
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';

module('Integration | Component | user-card', function(hooks) {
  setupRenderingTest(hooks);

  test('it renders user information', async function(assert) {
    this.set('user', {
      firstName: 'John',
      lastName: 'Doe',
      email: 'john@example.com'
    });

    await render(hbs`<UserCard @user={{this.user}} />`);

    assert.dom('[data-test-name]').hasText('John Doe');
    assert.dom('[data-test-email]').hasText('john@example.com');
  });

  test('it calls onEdit action when clicked', async function(assert) {
    assert.expect(1);

    this.set('onEdit', (user) => {
      assert.deepEqual(user, { name: 'John' });
    });

    await render(hbs`<UserCard @user={{this.user}} @onEdit={{this.onEdit}} />`);

    await click('[data-test-edit-button]');
  });
});

Best Practices

  1. 1Keep Components Focused: Each component should do one thing well
  2. 2Use Template-Only Components: When possible, avoid JavaScript
  3. 3Prevent Props Mutation: Never mutate @args directly
  4. 4Use Getters for Computed Values: Leverage @tracked and getters
  5. 5Document Public APIs: Clearly document arguments and yielded values
  6. 6Test Thoroughly: Write tests for all user interactions

Conclusion

Ember.js components are powerful and flexible. With Glimmer components and the Octane edition, component development is simpler and more performant than ever. Master these patterns to build maintainable, scalable applications.

Remember: components are the heart of your Ember app. Invest time in making them reusable, well-tested, and performant. Your future self (and your team) will thank you.

Enjoyed this essay? Share it.

Related essays

Next essay
Ember Octane: Modern Reactivity and Component Patterns