Files
marco/.agents/skills/ember-best-practices/AGENTS.md

228 KiB
Raw Blame History

Ember Best Practices

Version 1.0.0
Ember.js Community
January 2026

Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring Ember.js codebases. Humans
may also find it useful, but guidance here is optimized for automation
and consistency by AI-assisted workflows.


Abstract

Comprehensive performance optimization and accessibility guide for Ember.js applications, designed for AI agents and LLMs. Contains 42 rules across 7 categories, prioritized by impact from critical (route loading optimization, build performance) to advanced patterns (Resources, ember-concurrency, modern testing, composition patterns, owner/linkage management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. Uses WarpDrive for modern data management, includes accessibility best practices leveraging ember-a11y-testing and other OSS tools, and comprehensive coverage of reactive composition, data derivation, controlled forms, conditional rendering, data requesting patterns, built-in helpers, and service architecture patterns.


Table of Contents

  1. Route Loading and Data FetchingCRITICAL
  2. Build and Bundle OptimizationCRITICAL
  3. Component and Reactivity OptimizationHIGH
  4. Accessibility Best PracticesHIGH
  5. Service and State ManagementMEDIUM-HIGH
  6. Template OptimizationMEDIUM
  7. Performance OptimizationMEDIUM
  8. Testing Best PracticesMEDIUM
  9. Tooling and ConfigurationMEDIUM
  10. Advanced PatternsMEDIUM-HIGH

1. Route Loading and Data Fetching

Impact: CRITICAL

Efficient route loading and parallel data fetching eliminate waterfalls. Using route model hooks effectively and loading data in parallel yields the largest performance gains.

1.1 Implement Smart Route Model Caching

Impact: MEDIUM-HIGH (Reduce redundant API calls and improve UX)

Implement intelligent model caching strategies to reduce redundant API calls and improve user experience.

Incorrect: always fetches fresh data

// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class PostRoute extends Route {
  @service store;

  model(params) {
    // Always makes API call, even if we just loaded this post
    return this.store.request({ url: `/posts/${params.post_id}` });
  }

  <template>
    <article>
      <h1>{{@model.title}}</h1>
      <div>{{@model.content}}</div>
    </article>
    {{outlet}}
  </template>
}

Correct: with smart caching

// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class PostRoute extends Route {
  @service store;

  model(params) {
    // Check cache first
    const cached = this.store.cache.peek({
      type: 'post',
      id: params.post_id,
    });

    // Return cached if fresh (less than 5 minutes old)
    if (cached && this.isCacheFresh(cached)) {
      return cached;
    }

    // Fetch fresh data
    return this.store.request({
      url: `/posts/${params.post_id}`,
      options: { reload: true },
    });
  }

  isCacheFresh(record) {
    const cacheTime = record.meta?.cachedAt || 0;
    const fiveMinutes = 5 * 60 * 1000;
    return Date.now() - cacheTime < fiveMinutes;
  }

  <template>
    <article>
      <h1>{{@model.title}}</h1>
      <div>{{@model.content}}</div>
    </article>
    {{outlet}}
  </template>
}

Service-based caching layer:

// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class PostRoute extends Route {
  @service postCache;

  model(params) {
    return this.postCache.getPost(params.post_id);
  }

  // Refresh data when returning to route
  async activate() {
    super.activate(...arguments);
    const params = this.paramsFor('post');
    await this.postCache.getPost(params.post_id, { forceRefresh: true });
  }

  <template>
    <article>
      <h1>{{@model.title}}</h1>
      <div>{{@model.content}}</div>
    </article>
    {{outlet}}
  </template>
}

Using query params for cache control:

// app/routes/posts.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class PostsRoute extends Route {
  @service store;

  queryParams = {
    refresh: { refreshModel: true },
  };

  model(params) {
    const options = params.refresh ? { reload: true } : { backgroundReload: true };

    return this.store.request({
      url: '/posts',
      options,
    });
  }

  <template>
    <div class="posts">
      <button {{on "click" (fn this.refresh)}}>
        Refresh
      </button>

      <ul>
        {{#each @model as |post|}}
          <li>{{post.title}}</li>
        {{/each}}
      </ul>
    </div>
    {{outlet}}
  </template>
}

Background refresh pattern:

// app/routes/dashboard.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class DashboardRoute extends Route {
  @service store;

  async model() {
    // Return cached data immediately
    const cached = this.store.cache.peek({ type: 'dashboard' });

    // Refresh in background
    this.store.request({
      url: '/dashboard',
      options: { backgroundReload: true },
    });

    return cached || this.store.request({ url: '/dashboard' });
  }

  <template>
    <div class="dashboard">
      <h1>Dashboard</h1>
      <div>Stats: {{@model.stats}}</div>
    </div>
    {{outlet}}
  </template>
}

Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.

Reference: https://warp-drive.io/

1.2 Parallel Data Loading in Model Hooks

Impact: CRITICAL (2-10× improvement)

When fetching multiple independent data sources in a route's model hook, use Promise.all() or RSVP.hash() to load them in parallel instead of sequentially.

export default in these route examples is intentional because route modules are discovered through resolver lookup. In hybrid .gjs/.hbs codebases, keep route defaults and add named exports only when you need explicit imports elsewhere.

Incorrect: sequential loading, 3 round trips

// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class DashboardRoute extends Route {
  @service store;

  async model() {
    const user = await this.store.request({ url: '/users/me' });
    const posts = await this.store.request({ url: '/posts?recent=true' });
    const notifications = await this.store.request({ url: '/notifications?unread=true' });

    return { user, posts, notifications };
  }
}

Correct: parallel loading, 1 round trip

// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';

export default class DashboardRoute extends Route {
  @service store;

  model() {
    return hash({
      user: this.store.request({ url: '/users/me' }),
      posts: this.store.request({ url: '/posts?recent=true' }),
      notifications: this.store.request({ url: '/notifications?unread=true' }),
    });
  }
}

Using hash() from RSVP allows Ember to resolve all promises concurrently, significantly reducing load time.

1.3 Use Loading Substates for Better UX

Impact: CRITICAL (Perceived performance improvement)

Implement loading substates to show immediate feedback while data loads, preventing blank screens and improving perceived performance.

Incorrect: no loading state

// app/routes/posts.js
export default class PostsRoute extends Route {
  async model() {
    return this.store.request({ url: '/posts' });
  }
}

Correct: with loading substate

// app/routes/posts.js
export default class PostsRoute extends Route {
  model() {
    // Return promise directly - Ember will show posts-loading template
    return this.store.request({ url: '/posts' });
  }
}

Ember automatically renders {route-name}-loading route templates while the model promise resolves, providing better UX without extra code.

1.4 Use Route Templates with Co-located Syntax

Impact: MEDIUM-HIGH (Better code organization and maintainability)

Use co-located route templates with modern gjs syntax for better organization and maintainability.

Incorrect: separate template file - old pattern

// app/routes/posts.js (separate file)
import Route from '@ember/routing/route';

export default class PostsRoute extends Route {
  model() {
    return this.store.request({ url: '/posts' });
  }
}

// app/templates/posts.gjs (separate template file)
<template>
  <h1>Posts</h1>
  <ul>
    {{#each @model as |post|}}
      <li>{{post.title}}</li>
    {{/each}}
  </ul>
</template>

Correct: co-located route template

// app/routes/posts.gjs
import Route from '@ember/routing/route';

export default class PostsRoute extends Route {
  model() {
    return this.store.request({ url: '/posts' });
  }

  <template>
    <h1>Posts</h1>
    <ul>
      {{#each @model as |post|}}
        <li>{{post.title}}</li>
      {{/each}}
    </ul>

    {{outlet}}
  </template>
}

With loading and error states:

// app/routes/posts.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class PostsRoute extends Route {
  @service store;

  model() {
    return this.store.request({ url: '/posts' });
  }

  <template>
    <div class="posts-page">
      <h1>Posts</h1>

      {{#if @model}}
        <ul>
          {{#each @model as |post|}}
            <li>{{post.title}}</li>
          {{/each}}
        </ul>
      {{/if}}

      {{outlet}}
    </div>
  </template>
}

Template-only routes:

// app/routes/about.gjs
<template>
  <div class="about-page">
    <h1>About Us</h1>
    <p>Welcome to our application!</p>
  </div>
</template>

Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.

Reference: https://guides.emberjs.com/release/routing/

1.5 Use Route-Based Code Splitting

Impact: CRITICAL (30-70% initial bundle reduction)

With Embroider's route-based code splitting, routes and their components are automatically split into separate chunks, loaded only when needed.

Incorrect: everything in main bundle

// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {
    // No optimization
  });

  return app.toTree();
};

Correct: Embroider with Vite and route splitting

// ember-cli-build.js
const { Vite } = require('@embroider/vite');

module.exports = require('@embroider/compat').compatBuild(app, Vite, {
  staticAddonTestSupportTrees: true,
  staticAddonTrees: true,
  staticHelpers: true,
  staticModifiers: true,
  staticComponents: true,
  splitAtRoutes: ['admin', 'reports', 'settings'], // Routes to split
});

Embroider with splitAtRoutes creates separate bundles for specified routes, reducing initial load time by 30-70%.

Reference: https://github.com/embroider-build/embroider


2. Build and Bundle Optimization

Impact: CRITICAL

Using Embroider with static build optimizations, route-based code splitting, and proper imports reduces bundle size and improves Time to Interactive.

2.1 Avoid Importing Entire Addon Namespaces

Impact: CRITICAL (200-500ms import cost reduction)

Import specific utilities and components directly rather than entire addon namespaces to enable better tree-shaking and reduce bundle size.

Incorrect: imports entire namespace

import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
// OK - these are already optimized

// But avoid this pattern with utility libraries:
import * as lodash from 'lodash';
import * as moment from 'moment';

class My extends Component {
  someMethod() {
    return lodash.debounce(this.handler, 300);
  }
}

Correct: direct imports

import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import debounce from 'lodash/debounce';
import dayjs from 'dayjs'; // moment alternative, smaller

class My extends Component {
  someMethod() {
    return debounce(this.handler, 300);
  }
}

Even better: use Ember utilities when available

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

class My extends Component {
  someMethod() {
    return debounce(this, this.handler, 300);
  }
}

Direct imports and using built-in Ember utilities reduce bundle size by avoiding unused code.

2.2 Lazy Load Heavy Dependencies

Impact: CRITICAL (30-50% initial bundle reduction)

Use dynamic imports to load heavy libraries only when needed, reducing initial bundle size.

Incorrect: loaded upfront

import Component from '@glimmer/component';
import Chart from 'chart.js/auto'; // 300KB library loaded immediately
import hljs from 'highlight.js'; // 500KB library loaded immediately

class Dashboard extends Component {
  get showChart() {
    return this.args.hasData;
  }
}

Correct: lazy loaded with error/loading state handling

import Component from '@glimmer/component';
import { getPromiseState } from 'reactiveweb/promise';

class Dashboard extends Component {
  // Use getPromiseState to model promise state for error/loading handling
  chartLoader = getPromiseState(async () => {
    const { default: Chart } = await import('chart.js/auto');
    return Chart;
  });

  highlighterLoader = getPromiseState(async () => {
    const { default: hljs } = await import('highlight.js');
    return hljs;
  });

  loadChart = () => {
    // Triggers lazy load, handles loading/error states automatically
    return this.chartLoader.value;
  };

  highlightCode = (code) => {
    const hljs = this.highlighterLoader.value;
    if (hljs) {
      return hljs.highlightAuto(code);
    }
    return code;
  };

  <template>
    {{#if this.chartLoader.isLoading}}
      <p>Loading chart library...</p>
    {{else if this.chartLoader.isError}}
      <p>Error loading chart: {{this.chartLoader.error.message}}</p>
    {{else if this.chartLoader.isResolved}}
      <canvas {{on "click" this.loadChart}}></canvas>
    {{/if}}
  </template>
}

Note: Always model promise state (loading/error/resolved) using getPromiseState from reactiveweb/promise to handle slow networks and errors properly.

Dynamic imports reduce initial bundle size by 30-50%, improving Time to Interactive.

2.3 Use Embroider Build Pipeline

Impact: CRITICAL (Modern build system with better performance)

Use Embroider, Ember's modern build pipeline, with Vite for faster builds, better tree-shaking, and smaller bundles.

Incorrect: classic build pipeline

// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {});
  return app.toTree();
};

Correct: Embroider with Vite

// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');

module.exports = async function (defaults) {
  const { buildOnce } = await import('@embroider/vite');

  let app = new EmberApp(defaults, {
    // Add options here
  });

  return compatBuild(app, buildOnce);
};

For stricter static analysis: optimized mode

// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');

module.exports = async function (defaults) {
  const { buildOnce } = await import('@embroider/vite');

  let app = new EmberApp(defaults, {
    // Add options here
  });

  return compatBuild(app, buildOnce, {
    // Enable static analysis for better tree-shaking
    staticAddonTestSupportTrees: true,
    staticAddonTrees: true,
    staticHelpers: true,
    staticModifiers: true,
    staticComponents: true,
  });
};

Embroider provides a modern build pipeline with Vite that offers faster builds and better optimization compared to the classic Ember CLI build system.

Reference: https://github.com/embroider-build/embroider


3. Component and Reactivity Optimization

Impact: HIGH

Proper use of Glimmer components, modern file conventions, tracked properties, and avoiding unnecessary recomputation improves rendering performance.

3.1 Avoid Constructors in Components

Impact: HIGH (Prevents infinite render loops and simplifies code)

Strongly discourage constructor usage. Modern Ember components rarely need constructors. Use class fields, @service decorators, and getPromiseState for initialization instead. Constructors with function calls that set tracked state can cause infinite render loops.

Incorrect: using constructor

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';

class UserProfile extends Component {
  constructor() {
    super(...arguments);

    // Anti-pattern: Manual service lookup
    this.store = this.owner.lookup('service:store');
    this.router = this.owner.lookup('service:router');

    // Anti-pattern: Imperative initialization
    this.data = null;
    this.isLoading = false;
    this.error = null;

    // Anti-pattern: Side effects in constructor
    this.loadUserData();
  }

  async loadUserData() {
    this.isLoading = true;
    try {
      this.data = await this.store.request({
        url: `/users/${this.args.userId}`,
      });
    } catch (e) {
      this.error = e;
    } finally {
      this.isLoading = false;
    }
  }

  <template>
    {{#if this.isLoading}}
      <div>Loading...</div>
    {{else if this.error}}
      <div>Error: {{this.error.message}}</div>
    {{else if this.data}}
      <h1>{{this.data.name}}</h1>
    {{/if}}
  </template>
}

Correct: use class fields and declarative async state

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { service } from '@ember/service';
import { getRequestState } from '@warp-drive/ember';

class UserProfile extends Component {
  @service store;

  @cached
  get userRequest() {
    return this.store.request({
      url: `/users/${this.args.userId}`,
    });
  }

  <template>
    {{#let (getRequestState this.userRequest) as |state|}}
      {{#if state.isPending}}
        <div>Loading...</div>
      {{else if state.isError}}
        <div>Error loading user</div>
      {{else}}
        <h1>{{state.value.name}}</h1>
      {{/if}}
    {{/let}}
  </template>
}

When You Might Need a Constructor: Very Rare

// app/components/complex-setup.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

class ComplexSetup extends Component {
  @service store;

  @tracked state = null;

  constructor(owner, args) {
    super(owner, args);

    // Only if you absolutely must do something that can't be done with class fields
    // Even then, prefer resources or modifiers
    if (this.args.legacyInitMode) {
      this.initializeLegacyMode();
    }
  }

  initializeLegacyMode() {
    // Rare edge case initialization
  }

  <template>
    <!-- template -->
  </template>
}

Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns:

Why Strongly Avoid Constructors:

  1. Infinite Render Loops: Setting tracked state in constructor that's read during render causes infinite loops

  2. Service Injection: Use @service decorator instead of owner.lookup()

  3. Testability: Class fields are easier to mock and test

  4. Clarity: Declarative class fields show state at a glance

  5. Side Effects: getPromiseState and modifiers handle side effects better

  6. Memory Leaks: getPromiseState auto-cleanup; constructor code doesn't

  7. Reactivity: Class fields integrate better with tracking

  8. Initialization Order: No need to worry about super() call timing

  9. Argument Validation: Constructor validation runs only once; use getters to catch arg changes

Modern Alternatives:

| Old Pattern | Modern Alternative |

| -------------------------------------------------------------- | -------------------------------------------------------- |

| constructor() { this.store = owner.lookup('service:store') } | @service store; |

| constructor() { this.data = null; } | @tracked data = null; |

| constructor() { this.loadData(); } | Use @cached get with getPromiseState |

| constructor() { this.interval = setInterval(...) } | Use modifier with registerDestructor |

| constructor() { this.subscription = ... } | Use modifier or constructor with registerDestructor ONLY |

Performance Impact:

  • Before: Constructor runs on every instantiation, manual cleanup risk, infinite loop danger

  • After: Class fields initialize efficiently, getPromiseState auto-cleanup, no render loops

Strongly discourage constructors - they add complexity and infinite render loop risks. Use declarative class fields and getPromiseState instead.

3.2 Avoid CSS Classes in Learning Examples

Impact: LOW-MEDIUM (Cleaner, more focused learning materials)

Don't add CSS classes to learning content and examples unless they provide actual value above the surrounding context. Classes add visual noise and distract from the concepts being taught.

Incorrect: unnecessary classes in learning example

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

export class UserCard extends Component {
  <template>
    <div class="user-card">
      <div class="user-card__header">
        <h3 class="user-card__name">{{@user.name}}</h3>
        <p class="user-card__email">{{@user.email}}</p>
      </div>

      {{#if @user.avatarUrl}}
        <img class="user-card__avatar" src={{@user.avatarUrl}} alt={{@user.name}} />
      {{/if}}

      {{#if @onEdit}}
        <button class="user-card__edit-button" {{on "click" (fn @onEdit @user)}}>
          Edit
        </button>
      {{/if}}

      <div class="user-card__content">
        {{yield}}
      </div>
    </div>
  </template>
}

Why This Is Wrong:

  • Classes add visual clutter that obscures the actual concepts

  • Learners focus on naming conventions instead of the pattern being taught

  • Makes copy-paste more work (need to remove or change class names)

  • Implies these specific class names are required or best practice

  • Distracts from structural HTML and component logic

Correct: focused on concepts

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

export class UserCard extends Component {
  <template>
    <div ...attributes>
      <h3>{{@user.name}}</h3>
      <p>{{@user.email}}</p>

      {{#if @user.avatarUrl}}
        <img src={{@user.avatarUrl}} alt={{@user.name}} />
      {{/if}}

      {{#if @onEdit}}
        <button {{on "click" (fn @onEdit @user)}}>Edit</button>
      {{/if}}

      {{yield}}
    </div>
  </template>
}

Benefits:

  • Clarity: Easier to understand the component structure

  • Focus: Reader attention stays on the concepts being taught

  • Simplicity: Less code to process mentally

  • Flexibility: Reader can add their own classes without conflict

  • Reusability: Examples are easier to adapt to real code

When Classes ARE Appropriate in Examples:

// Example: Teaching about ...attributes for styling flexibility
export class Card extends Component {
  <template>
    {{! Caller can add their own classes via ...attributes }}
    <div ...attributes>
      {{yield}}
    </div>
  </template>
}

{{! Usage: <Card class="user-card">...</Card> }}

When to Include Classes:

  1. Teaching class binding - Example explicitly about conditional classes or class composition

  2. Demonstrating ...attributes - Showing how callers add classes

  3. Accessibility - Using classes for semantic meaning (e.g., aria-* helpers)

  4. Critical to example - Class name is essential to understanding (e.g., selected, active)

Examples Where Classes Add Value:

// Good: Teaching about class composition
import { cn } from 'ember-cn';

export class Button extends Component {
  <template>
    <button class={{cn "btn" (if @primary "btn-primary" "btn-secondary")}}>
      {{yield}}
    </button>
  </template>
}

Default Stance:

When writing learning examples or documentation:

  1. Start without classes - Add them only if needed

  2. Ask: Does this class help explain the concept?

  3. Remove any decorative or structural classes that aren't essential

  4. Use ...attributes to show styling flexibility

Real-World Context:

In production code, you'll have classes for styling. But in learning materials, strip them away unless they're teaching something specific about classes themselves.

Common Violations:

BEM classes in examples (user-card__header)

Utility classes unless teaching utilities (flex, mt-4)

Semantic classes that don't teach anything (container, wrapper)

Design system classes unless teaching design system integration

Summary:

Keep learning examples focused on the concept being taught. CSS classes should appear only when they're essential to understanding the pattern or when demonstrating styling flexibility with ...attributes.

Reference: https://guides.emberjs.com/release/components/

3.3 Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)

Impact: HIGH (Prevents memory leaks and enforces modern patterns)

Never use {{did-insert}}, {{will-destroy}}, or {{did-update}} in new code. These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers.

  1. Memory Leaks: Easy to forget cleanup, especially with did-insert

  2. Tight Coupling: Mixes template concerns with JavaScript logic

  3. Poor Testability: Lifecycle hooks are harder to unit test

  4. Not Composable: Can't be easily shared across components

  5. Deprecated Pattern: Not recommended in modern Ember

For computed values or reactive transformations, use getters and @cached:

Incorrect (did-update):

// app/components/user-greeting.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

class UserGreeting extends Component {
  @tracked displayName = '';

  @action
  updateDisplayName() {
    // Runs on every render - inefficient and error-prone
    this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`;
  }

  <template>
    <div {{did-update this.updateDisplayName @user}}>
      Hello,
      {{this.displayName}}
    </div>
  </template>
}

Correct (derived data with getter):

// app/components/user-greeting.gjs
import Component from '@glimmer/component';

class UserGreeting extends Component {
  // Automatically reactive - updates when args change
  get displayName() {
    return `${this.args.user.firstName} ${this.args.user.lastName}`;
  }

  <template>
    <div>
      Hello,
      {{this.displayName}}
    </div>
  </template>
}

Even better (use @cached for expensive computations):

// app/components/user-stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';

class UserStats extends Component {
  @cached
  get sortedPosts() {
    // Expensive computation only runs when @posts changes
    return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt);
  }

  @cached
  get statistics() {
    return {
      total: this.args.posts.length,
      published: this.args.posts.filter((p) => p.published).length,
      drafts: this.args.posts.filter((p) => !p.published).length,
    };
  }

  <template>
    <div>
      <p>Total: {{this.statistics.total}}</p>
      <p>Published: {{this.statistics.published}}</p>
      <p>Drafts: {{this.statistics.drafts}}</p>

      <ul>
        {{#each this.sortedPosts as |post|}}
          <li>{{post.title}}</li>
        {{/each}}
      </ul>
    </div>
  </template>
}

For DOM side effects, element setup, or cleanup, use custom modifiers:

Incorrect (did-insert + will-destroy):

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

class Chart extends Component {
  chartInstance = null;

  @action
  setupChart(element) {
    this.chartInstance = new Chart(element, this.args.config);
  }

  willDestroy() {
    super.willDestroy();
    // Easy to forget cleanup!
    this.chartInstance?.destroy();
  }

  <template>
    <canvas {{did-insert this.setupChart}}></canvas>
  </template>
}

Correct (custom modifier with automatic cleanup):

// app/components/chart.gjs
import chart from '../modifiers/chart';

<template>
  <canvas {{chart @config}}></canvas>
</template>

For complex state management with automatic cleanup, use ember-resources:

Incorrect (did-insert for data fetching):

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

class UserProfile extends Component {
  @tracked userData = null;
  @tracked loading = true;
  controller = new AbortController();

  @action
  async loadUser() {
    this.loading = true;
    try {
      const response = await fetch(`/api/users/${this.args.userId}`, {
        signal: this.controller.signal,
      });
      this.userData = await response.json();
    } finally {
      this.loading = false;
    }
  }

  willDestroy() {
    super.willDestroy();
    this.controller.abort(); // Easy to forget!
  }

  <template>
    <div {{did-insert this.loadUser}}>
      {{#if this.loading}}
        Loading...
      {{else}}
        {{this.userData.name}}
      {{/if}}
    </div>
  </template>
}

Correct (Resource with automatic cleanup):

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import UserDataResource from '../resources/user-data';

class UserProfile extends Component {
  userData = UserDataResource.from(this, () => [this.args.userId]);

  <template>
    {{#if this.userData.loading}}
      Loading...
    {{else}}
      {{this.userData.data.name}}
    {{/if}}
  </template>
}

| Use Case | Solution | Why |

| ---------------- | ----------------------------------- | ----------------------------------------- |

| Computed values | Getters + @cached | Reactive, efficient, no lifecycle needed |

| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup |

| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup |

| Event listeners | {{on}} modifier | Built-in, automatic cleanup |

| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility |

If you have existing code using these hooks:

  1. Identify the purpose: What is the hook doing?

  2. Choose the right alternative:

    • Deriving data? → Use getters/@cached

    • DOM setup/teardown? → Use a custom modifier

    • Async data loading? → Use getPromiseState from warp-drive

  3. Test thoroughly: Ensure cleanup happens correctly

  4. Remove the legacy hook: Delete {{did-insert}}, {{will-destroy}}, or {{did-update}}

Modern alternatives provide better performance:

  • Getters: Only compute when dependencies change

  • @cached: Memoizes expensive computations

  • Modifiers: Scoped to specific elements, composable

  • getPromiseState: Declarative data loading, automatic cleanup

Don't use willDestroy() for cleanup when a modifier would work

Don't use @action + did-insert when a getter would suffice

Don't manually track changes when @cached handles it automatically

Don't forget registerDestructor in custom modifiers

Modern Ember provides superior alternatives to legacy lifecycle hooks:

  • Derived Data: Use getters and @cached for reactive computations

  • DOM Side Effects: Use custom modifiers with registerDestructor

  • Async Data Loading: Use getPromiseState from warp-drive/reactiveweb

  • Better Code: More testable, composable, and maintainable

Never use {{did-insert}}, {{will-destroy}}, or {{did-update}} in new code.

3.4 Avoid Unnecessary Tracking

Impact: HIGH (20-40% fewer invalidations)

Only mark properties as @tracked if they need to trigger re-renders when changed. Overusing @tracked causes unnecessary invalidations and re-renders.

Incorrect: everything tracked

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

class Form extends Component {
  @tracked firstName = ''; // Used in template ✓
  @tracked lastName = ''; // Used in template ✓
  @tracked _formId = Date.now(); // Internal, never rendered ✗
  @tracked _validationCache = new Map(); // Internal state ✗

  @action
  validate() {
    this._validationCache.set('firstName', this.firstName.length > 0);
    // Unnecessary re-render triggered
  }
}

Correct: selective tracking

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

class Form extends Component {
  @tracked firstName = ''; // Rendered in template
  @tracked lastName = ''; // Rendered in template
  @tracked isValid = false; // Rendered status

  _formId = Date.now(); // Not tracked - internal only
  _validationCache = new Map(); // Not tracked - internal state

  @action
  validate() {
    this._validationCache.set('firstName', this.firstName.length > 0);
    this.isValid = this._validationCache.get('firstName');
    // Only re-renders when isValid changes
  }
}

Only track properties that directly affect the template or other tracked getters to minimize unnecessary re-renders.

3.5 Build Reactive Chains with Dependent Getters

Impact: HIGH (Clear data flow and automatic reactivity)

Create reactive chains where getters depend on other getters or tracked properties for clear, maintainable data derivation.

Incorrect: imperative updates

// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

class ShoppingCart extends Component {
  @tracked items = [];
  @tracked subtotal = 0;
  @tracked tax = 0;
  @tracked shipping = 0;
  @tracked total = 0;

  @action
  addItem(item) {
    this.items = [...this.items, item];
    this.recalculate();
  }

  @action
  removeItem(index) {
    this.items = this.items.filter((_, i) => i !== index);
    this.recalculate();
  }

  recalculate() {
    this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
    this.tax = this.subtotal * 0.08;
    this.shipping = this.subtotal > 50 ? 0 : 5.99;
    this.total = this.subtotal + this.tax + this.shipping;
  }

  <template>
    <div class="cart">
      <div>Subtotal: ${{this.subtotal}}</div>
      <div>Tax: ${{this.tax}}</div>
      <div>Shipping: ${{this.shipping}}</div>
      <div>Total: ${{this.total}}</div>
    </div>
  </template>
}

Correct: reactive getter chains

// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';

class ShoppingCart extends Component {
  @tracked items = new TrackedArray([]);

  // Base calculation
  get subtotal() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  // Depends on subtotal
  get tax() {
    return this.subtotal * 0.08;
  }

  // Depends on subtotal
  get shipping() {
    return this.subtotal > 50 ? 0 : 5.99;
  }

  // Depends on subtotal, tax, and shipping
  get total() {
    return this.subtotal + this.tax + this.shipping;
  }

  // Derived from total
  get formattedTotal() {
    return `$${this.total.toFixed(2)}`;
  }

  // Multiple dependencies
  get discount() {
    if (this.items.length >= 5) return this.subtotal * 0.1;
    if (this.subtotal > 100) return this.subtotal * 0.05;
    return 0;
  }

  // Depends on total and discount
  get finalTotal() {
    return this.total - this.discount;
  }

  @action
  addItem(item) {
    this.items.push(item);
    // All getters automatically update!
  }

  @action
  removeItem(index) {
    this.items.splice(index, 1);
    // All getters automatically update!
  }

  <template>
    <div class="cart">
      <div>Items: {{this.items.length}}</div>
      <div>Subtotal: ${{this.subtotal.toFixed 2}}</div>
      <div>Tax: ${{this.tax.toFixed 2}}</div>
      <div>Shipping: ${{this.shipping.toFixed 2}}</div>
      {{#if this.discount}}
        <div class="discount">Discount: -${{this.discount.toFixed 2}}</div>
      {{/if}}
      <div class="total">Total: {{this.formattedTotal}}</div>
    </div>
  </template>
}

Complex reactive chains with @cached:

// app/components/data-analysis.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';

class DataAnalysis extends Component {
  // Base data
  get rawData() {
    return this.args.data || [];
  }

  // Level 1: Filter
  @cached
  get validData() {
    return this.rawData.filter((item) => item.value != null);
  }

  // Level 2: Transform (depends on validData)
  @cached
  get normalizedData() {
    const max = Math.max(...this.validData.map((d) => d.value));
    return this.validData.map((item) => ({
      ...item,
      normalized: item.value / max,
    }));
  }

  // Level 2: Statistics (depends on validData)
  @cached
  get statistics() {
    const values = this.validData.map((d) => d.value);
    const sum = values.reduce((a, b) => a + b, 0);
    const mean = sum / values.length;
    const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length;

    return {
      count: values.length,
      sum,
      mean,
      stdDev: Math.sqrt(variance),
      min: Math.min(...values),
      max: Math.max(...values),
    };
  }

  // Level 3: Depends on normalizedData and statistics
  @cached
  get outliers() {
    const threshold = this.statistics.mean + 2 * this.statistics.stdDev;
    return this.normalizedData.filter((item) => item.value > threshold);
  }

  // Level 3: Depends on statistics
  get qualityScore() {
    const validRatio = this.validData.length / this.rawData.length;
    const outlierRatio = this.outliers.length / this.validData.length;
    return validRatio * 0.7 + (1 - outlierRatio) * 0.3;
  }

  <template>
    <div class="analysis">
      <h3>Data Quality: {{this.qualityScore.toFixed 2}}</h3>
      <div>Valid: {{this.validData.length}} / {{this.rawData.length}}</div>
      <div>Mean: {{this.statistics.mean.toFixed 2}}</div>
      <div>Std Dev: {{this.statistics.stdDev.toFixed 2}}</div>
      <div>Outliers: {{this.outliers.length}}</div>
    </div>
  </template>
}

Combining multiple tracked sources:

// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';

class FilteredList extends Component {
  @tracked searchTerm = '';
  @tracked selectedCategory = 'all';
  @tracked sortDirection = 'asc';

  // Depends on args.items and searchTerm
  @cached
  get searchFiltered() {
    if (!this.searchTerm) return this.args.items;

    const term = this.searchTerm.toLowerCase();
    return this.args.items.filter(
      (item) =>
        item.name.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
    );
  }

  // Depends on searchFiltered and selectedCategory
  @cached
  get categoryFiltered() {
    if (this.selectedCategory === 'all') return this.searchFiltered;

    return this.searchFiltered.filter((item) => item.category === this.selectedCategory);
  }

  // Depends on categoryFiltered and sortDirection
  @cached
  get sorted() {
    const items = [...this.categoryFiltered];
    const direction = this.sortDirection === 'asc' ? 1 : -1;

    return items.sort((a, b) => direction * a.name.localeCompare(b.name));
  }

  // Final result
  get items() {
    return this.sorted;
  }

  // Metadata derived from chain
  get resultsCount() {
    return this.items.length;
  }

  get hasFilters() {
    return this.searchTerm || this.selectedCategory !== 'all';
  }

  <template>
    <div class="filtered-list">
      <input
        type="search"
        value={{this.searchTerm}}
        {{on "input" (pick "target.value" (set this "searchTerm"))}}
      />

      <select
        value={{this.selectedCategory}}
        {{on "change" (pick "target.value" (set this "selectedCategory"))}}
      >
        <option value="all">All Categories</option>
        {{#each @categories as |cat|}}
          <option value={{cat}}>{{cat}}</option>
        {{/each}}
      </select>

      <p>Showing {{this.resultsCount}} results</p>

      {{#each this.items as |item|}}
        <div>{{item.name}}</div>
      {{/each}}
    </div>
  </template>
}

Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached.

Reference: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/

3.6 Component File Naming and Export Conventions

Impact: HIGH (Enforces consistent component structure and predictable imports)

Follow modern Ember component file conventions: use .gjs/.gts files with <template> tags (never .hbs files), use kebab-case filenames, match class names to file names (in PascalCase), do not use the Component suffix in class names, and avoid export default in .gjs/.gts component files.

This export guidance applies to .gjs/.gts component files only. If your app still uses .hbs, keep default exports for resolver-facing invokables used there (or use a named export plus default alias in hybrid codebases).

Incorrect:

// app/components/UserProfile.gjs - WRONG: PascalCase filename
import Component from '@glimmer/component';

export class UserProfile extends Component {
  <template>
    <div class="profile">
      {{@name}}
    </div>
  </template>
}

Correct:

// app/components/user-profile.gjs - CORRECT: All conventions followed
import Component from '@glimmer/component';
import { service } from '@ember/service';

export class UserProfile extends Component {
  @service session;

  <template>
    <div class="profile">
      <h1>{{@name}}</h1>
      {{#if this.session.isAuthenticated}}
        <button>Edit Profile</button>
      {{/if}}
    </div>
  </template>
}

Never use .hbs files:

  • .gjs/.gts files with <template> tags are the modern standard

  • Co-located templates and logic in a single file improve maintainability

  • Better tooling support (type checking, imports, refactoring)

  • Enables strict mode and proper scope

  • Avoid split between .js and .hbs files which makes components harder to understand

Filename conventions:

  • Kebab-case filenames (user-card.gjs, not UserCard.gjs) follow web component standards and Ember conventions

  • Predictable: component name maps directly to filename (UserCard → user-card.gjs)

  • Avoids filesystem case-sensitivity issues across platforms

Class naming:

  • No "Component" suffix - it's redundant (extends Component already declares the type)

  • PascalCase class name matches the capitalized component invocation: <UserCard />

  • Cleaner code: UserCard vs UserCardComponent

No default export:

  • Modern .gjs/.gts files don't need export default

  • The template compiler automatically exports the component

  • Simpler syntax, less boilerplate

  • Consistent with strict-mode semantics

| Filename | Class Name | Template Invocation |

| --------------------- | ---------------------- | -------------------- |

| user-card.gjs | class UserCard | <UserCard /> |

| loading-spinner.gjs | class LoadingSpinner | <LoadingSpinner /> |

| nav-bar.gjs | class NavBar | <NavBar /> |

| todo-list.gjs | class TodoList | <TodoList /> |

| search-input.gjs | class SearchInput | <SearchInput /> |

Conversion rule:

  • Filename: all lowercase, words separated by hyphens

  • Class: PascalCase, same words, no hyphens

  • user-card.gjsclass UserCard

Template-only components:

// app/components/simple-card.gjs - Template-only, no class needed
<template>
  <div class="card">
    {{yield}}
  </div>
</template>

Components in subdirectories:

// app/components/ui/button.gjs
import Component from '@glimmer/component';

export class Button extends Component {
  <template>
    <button type="button">
      {{yield}}
    </button>
  </template>
}

// Usage: <Ui::Button />

Nested namespaces:

// app/components/admin/user/profile-card.gjs
import Component from '@glimmer/component';

export class ProfileCard extends Component {
  <template>
    <div class="admin-profile">
      {{@user.name}}
    </div>
  </template>
}

// Usage: <Admin::User::ProfileCard />

Positive:

  • Cleaner, more maintainable code

  • 🎯 Predictable mapping between files and classes

  • 🌐 Follows web standards (kebab-case)

  • 📦 Smaller bundle size (less export overhead)

  • 🚀 Better alignment with modern Ember/Glimmer

Negative:

  • None - this is the modern standard

  • Code clarity: +30% (shorter, clearer names)

  • Bundle size: -5-10 bytes per component (no export overhead)

  • Developer experience: Improved (predictable naming)

  • Ember Components Guide

  • Glimmer Components

  • Template Tag Format RFC

  • Strict Mode Semantics

  • component-use-glimmer.md - Modern Glimmer component patterns

  • component-strict-mode.md - Template-only components and strict mode

  • route-templates.md - Route file naming conventions

3.7 Prefer Named Exports, Fallback to Default for Implicit Template Lookup

Impact: LOW (Clear export contracts across .hbs and template-tag codebases)

Use named exports for shared modules imported directly in JS/TS (utilities, constants, pure functions). If a module should be invokable from .hbs templates via implicit lookup, provide a default export. In hybrid .gjs/.hbs projects, a practical pattern is a named export plus a default export alias.

Incorrect: default export in a shared utility module

// app/utils/format-date.js
export default function formatDate(date) {
  return new Date(date).toLocaleDateString();
}

Correct: named export in a shared utility module

// app/utils/format-date.js
export function formatDate(date) {
  return new Date(date).toLocaleDateString();
}

Correct: hybrid .gjs/.hbs named export + default alias

// app/helpers/format-date.js
import { helper } from '@ember/component/helper';

export const formatDate = helper(([value]) => {
  return new Date(value).toLocaleDateString();
});

export default formatDate;

Use named exports when the module is imported directly by other modules and is not resolved via implicit template lookup.

Example: utility module with multiple named exports

// app/utils/validators.js
export function isEmail(value) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

export function isPhoneNumber(value) {
  return /^\d{3}-\d{3}-\d{4}$/.test(value);
}

Benefits:

  1. Explicit import contracts

  2. Better refactor safety (symbol rename tracking)

  3. Better tree-shaking for utility modules

  4. Easier multi-export module organization

Use default exports for modules consumed through resolver/template lookup.

If your project uses .hbs, invokables that should be accessible from templates should provide export default.

In hybrid .gjs/.hbs codebases, use named exports plus a default export alias where you want both explicit imports and template compatibility.

Service:

// app/services/auth.js
import Service from '@ember/service';

export default class AuthService extends Service {
  // ...
}

Route:

// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class DashboardRoute extends Route {
  @service store;

  model() {
    return this.store.findAll('dashboard-item');
  }
}

Modifier: when invoked from .hbs

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

export default modifier((element) => {
  element.focus();
});

Template: .gjs

// app/templates/dashboard.gjs
<template>
  <h1>Dashboard</h1>
</template>

Template: .gts

// app/templates/dashboard.gts
import type { TOC } from '@ember/component/template-only';

interface Signature {
  Args: {
    model: unknown;
  };
}

export default <template>
  <h1>Dashboard</h1>
</template> satisfies TOC<Signature>;

Template-tag files must resolve via a module default export in convention-based and import.meta.glob flows.

For app/templates/*.gjs, the default export is implicit after compilation.

With ember-strict-application-resolver, you can register explicit module values in App.modules:

Strict resolver explicit modules registration:

modules = {
  './services/manual': { default: ManualService },
  './services/manual-shorthand': ManualService,
};

In that explicit shorthand case, a direct value works without a default-exported module object.

This is an explicit registration escape hatch and does not replace default-export requirements for .hbs-invokable modules.

  1. If a module should be invokable from .hbs, provide a default export.

  2. In hybrid .gjs/.hbs projects, use named export + default alias for resolver-facing modules.

  3. Strict resolver explicit modules entries may use direct shorthand values where appropriate.

  4. Plain shared modules (app/utils, shared constants, reusable pure functions): prefer named exports.

  5. Template-tag components (.gjs/.gts): follow the component file-conventions rule and use named class exports.

3.8 Prevent Memory Leaks in Components

Impact: HIGH (Avoid memory leaks and resource exhaustion)

Properly clean up event listeners, timers, and subscriptions to prevent memory leaks.

Incorrect: no cleanup

// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

class LiveClock extends Component {
  @tracked time = new Date();

  constructor() {
    super(...arguments);

    // Memory leak: interval never cleared
    setInterval(() => {
      this.time = new Date();
    }, 1000);
  }

  <template>
    <div>{{this.time}}</div>
  </template>
}

Correct: proper cleanup with registerDestructor

// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';

class LiveClock extends Component {
  @tracked time = new Date();

  constructor() {
    super(...arguments);

    const intervalId = setInterval(() => {
      this.time = new Date();
    }, 1000);

    // Proper cleanup
    registerDestructor(this, () => {
      clearInterval(intervalId);
    });
  }

  <template>
    <div>{{this.time}}</div>
  </template>
}

Event listener cleanup:

// app/components/window-size.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';

class WindowSize extends Component {
  @tracked width = window.innerWidth;
  @tracked height = window.innerHeight;

  constructor() {
    super(...arguments);

    const handleResize = () => {
      this.width = window.innerWidth;
      this.height = window.innerHeight;
    };

    window.addEventListener('resize', handleResize);

    registerDestructor(this, () => {
      window.removeEventListener('resize', handleResize);
    });
  }

  <template>
    <div>Window: {{this.width}} x {{this.height}}</div>
  </template>
}

Using modifiers for automatic cleanup:

// app/components/resize-aware.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import windowListener from '../modifiers/window-listener';

class ResizeAware extends Component {
  @tracked size = { width: 0, height: 0 };

  handleResize = () => {
    this.size = {
      width: window.innerWidth,
      height: window.innerHeight,
    };
  };

  <template>
    <div {{windowListener "resize" this.handleResize}}>
      {{this.size.width}}
      x
      {{this.size.height}}
    </div>
  </template>
}

Abort controller for fetch requests:

// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';

class DataLoader extends Component {
  @tracked data = null;
  abortController = new AbortController();

  constructor() {
    super(...arguments);

    this.loadData();

    registerDestructor(this, () => {
      this.abortController.abort();
    });
  }

  async loadData() {
    try {
      const response = await fetch('/api/data', {
        signal: this.abortController.signal,
      });
      this.data = await response.json();
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Failed to load data:', error);
      }
    }
  }

  <template>
    {{#if this.data}}
      <div>{{this.data.content}}</div>
    {{/if}}
  </template>
}

Using ember-resources for automatic cleanup:

// app/components/websocket-data.gjs
import Component from '@glimmer/component';
import { resource } from 'ember-resources';

class WebsocketData extends Component {
  messages = resource(({ on }) => {
    const messages = [];
    const ws = new WebSocket('wss://example.com/socket');

    ws.onmessage = (event) => {
      messages.push(event.data);
    };

    // Automatic cleanup
    on.cleanup(() => {
      ws.close();
    });

    return messages;
  });

  <template>
    {{#each this.messages.value as |message|}}
      <div>{{message}}</div>
    {{/each}}
  </template>
}

Always clean up timers, event listeners, subscriptions, and pending requests to prevent memory leaks and performance degradation.

Reference: https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable

3.9 Use {{on}} Modifier for Event Handling

Impact: MEDIUM (Better memory management and clarity)

Use the {{on}} modifier for event handling instead of traditional action handlers for better memory management and clearer code.

Incorrect: traditional action attribute

// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

class Button extends Component {
  @action
  handleClick() {
    this.args.onClick?.();
  }

  <template>
    <button onclick={{this.handleClick}}>
      {{@label}}
    </button>
  </template>
}

Correct: using {{on}} modifier

// app/components/button.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';

class Button extends Component {
  handleClick = () => {
    this.args.onClick?.();
  };

  <template>
    <button {{on "click" this.handleClick}}>
      {{@label}}
    </button>
  </template>
}

With event options:

// app/components/scroll-tracker.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';

class ScrollTracker extends Component {
  handleScroll = (event) => {
    console.log('Scroll position:', event.target.scrollTop);
  };

  <template>
    <div class="scrollable" {{on "scroll" this.handleScroll passive=true}}>
      {{yield}}
    </div>
  </template>
}

Multiple event handlers:

// app/components/input-field.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';

class InputField extends Component {
  @tracked isFocused = false;

  handleFocus = () => {
    this.isFocused = true;
  };

  handleBlur = () => {
    this.isFocused = false;
  };

  handleInput = (event) => {
    this.args.onInput?.(event.target.value);
  };

  <template>
    <input
      type="text"
      class={{if this.isFocused "focused"}}
      {{on "focus" this.handleFocus}}
      {{on "blur" this.handleBlur}}
      {{on "input" this.handleInput}}
      value={{@value}}
    />
  </template>
}

Using fn helper for arguments:

// app/components/item-list.gjs
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';

<template>
  <ul>
    {{#each @items as |item|}}
      <li>
        {{item.name}}
        <button {{on "click" (fn @onDelete item.id)}}>
          Delete
        </button>
      </li>
    {{/each}}
  </ul>
</template>

The {{on}} modifier properly cleans up event listeners, supports event options (passive, capture, once), and makes event handling more explicit.

Reference: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers

3.10 Use @cached for Expensive Getters

Impact: HIGH (50-90% reduction in recomputation)

Use @cached from @glimmer/tracking to memoize expensive computations that depend on tracked properties. The cached value is automatically invalidated when dependencies change.

Incorrect: recomputes on every access

import Component from '@glimmer/component';

class DataTable extends Component {
  get filteredAndSortedData() {
    // Expensive: runs on every access, even if nothing changed
    return this.args.data
      .filter((item) => item.status === this.args.filter)
      .sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
      .map((item) => this.transformItem(item));
  }
}

Correct: cached computation

import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';

class DataTable extends Component {
  @cached
  get filteredAndSortedData() {
    // Computed once per unique combination of dependencies
    return this.args.data
      .filter((item) => item.status === this.args.filter)
      .sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
      .map((item) => this.transformItem(item));
  }

  transformItem(item) {
    // Expensive transformation
    return { ...item, computed: this.expensiveCalculation(item) };
  }
}

@cached memoizes the getter result and only recomputes when tracked dependencies change, providing 50-90% reduction in unnecessary work.

Reference: https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/#toc_caching

3.11 Use Class Fields for Component Composition

Impact: MEDIUM-HIGH (Better composition and initialization patterns)

Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be roots of state - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.

Incorrect: imperative initialization, scattered state

// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';

class DataManager extends Component {
  @service store;
  @service router;

  // Scattered state management - hard to track relationships
  @tracked currentUser = null;
  @tracked isLoading = false;
  @tracked error = null;

  loadData = async () => {
    this.isLoading = true;
    try {
      this.currentUser = await this.store.request({ url: '/users/me' });
    } catch (e) {
      this.error = e;
    } finally {
      this.isLoading = false;
    }
  };

  <template>
    <div>{{this.currentUser.name}}</div>
  </template>
}

Correct: class fields with proper patterns

// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';

class DataManager extends Component {
  // Service injection as class fields
  @service store;
  @service router;

  // Tracked state as class fields - this is a "root of state"
  // Most components should have very few of these
  @tracked selectedFilter = 'all';

  // Data loading with getPromiseState
  @cached
  get currentUser() {
    const promise = this.store.request({
      url: '/users/me',
    });
    return getPromiseState(promise);
  }

  <template>
    {{#if this.currentUser.isFulfilled}}
      <div>{{this.currentUser.value.name}}</div>
    {{else if this.currentUser.isRejected}}
      <div>Error: {{this.currentUser.error.message}}</div>
    {{/if}}
  </template>
}

Understanding "roots of state":

Tracked fields should represent independent state that your component owns - not derived data or loaded data. Examples of good tracked fields:

  • User selections (selected tab, filter option)

  • UI state (is modal open, is expanded)

  • Form input values (not yet persisted)

In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.

Composition through class field assignment:

// app/components/form-container.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';

class FormContainer extends Component {
  // Compose form state
  @tracked formData = new TrackedObject({
    firstName: '',
    lastName: '',
    email: '',
    preferences: {
      newsletter: false,
      notifications: true,
    },
  });

  // Compose validation state
  @tracked errors = new TrackedObject({});

  // Compose UI state
  @tracked ui = new TrackedObject({
    isSubmitting: false,
    isDirty: false,
    showErrors: false,
  });

  // Computed field based on composed state
  get isValid() {
    return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
  }

  get canSubmit() {
    return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
  }

  updateField = (field, value) => {
    this.formData[field] = value;
    this.ui.isDirty = true;
    this.validate(field, value);
  };

  validate(field, value) {
    if (field === 'email' && !value.includes('@')) {
      this.errors.email = 'Invalid email';
    } else {
      delete this.errors[field];
    }
  }

  <template>
    <form>
      <input
        value={{this.formData.firstName}}
        {{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
      />

      <button disabled={{not this.canSubmit}}>
        Submit
      </button>
    </form>
  </template>
}

Mixin-like composition with class fields:

// app/components/paginated-list.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { PaginationState } from '../utils/pagination-mixin';

class PaginatedList extends Component {
  // Compose pagination functionality
  pagination = new PaginationState();

  @cached
  get paginatedItems() {
    const start = this.pagination.offset;
    const end = start + this.pagination.perPage;
    return this.args.items.slice(start, end);
  }

  get totalPages() {
    return Math.ceil(this.args.items.length / this.pagination.perPage);
  }

  <template>
    <div class="list">
      {{#each this.paginatedItems as |item|}}
        <div>{{item.name}}</div>
      {{/each}}

      <div class="pagination">
        <button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
          Previous
        </button>

        <span>Page {{this.pagination.page}} of {{this.totalPages}}</span>

        <button
          {{on "click" this.pagination.nextPage}}
          disabled={{eq this.pagination.page this.totalPages}}
        >
          Next
        </button>
      </div>
    </div>
  </template>
}

Shareable state objects:

// app/components/selectable-list.gjs
import Component from '@glimmer/component';
import { SelectionState } from '../utils/selection-state';

class SelectableList extends Component {
  // Compose selection behavior
  selection = new SelectionState();

  get selectedItems() {
    return this.args.items.filter((item) => this.selection.isSelected(item.id));
  }

  <template>
    <div class="toolbar">
      <button {{on "click" (fn this.selection.selectAll @items)}}>
        Select All
      </button>
      <button {{on "click" this.selection.clear}}>
        Clear
      </button>
      <span>{{this.selection.count}} selected</span>
    </div>

    <ul>
      {{#each @items as |item|}}
        <li class={{if (this.selection.isSelected item.id) "selected"}}>
          <input
            type="checkbox"
            checked={{this.selection.isSelected item.id}}
            {{on "change" (fn this.selection.toggle item.id)}}
          />
          {{item.name}}
        </li>
      {{/each}}
    </ul>

    {{#if this.selection.hasSelection}}
      <div class="actions">
        <button>Delete {{this.selection.count}} items</button>
      </div>
    {{/if}}
  </template>
}

Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.

Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields

3.12 Use Component Composition Patterns

Impact: HIGH (Better code reuse and maintainability)

Use component composition with yield blocks, named blocks, and contextual components for flexible, reusable UI patterns.

Named blocks are for invocation consistency in design systems where you don't want the caller to have full markup control. They provide structured extension points while maintaining design system constraints - the same concept as named slots in other frameworks.

Incorrect: monolithic component

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

class UserCard extends Component {
  <template>
    <div class="user-card">
      <div class="header">
        <img src={{@user.avatar}} alt={{@user.name}} />
        <h3>{{@user.name}}</h3>
        <p>{{@user.email}}</p>
      </div>

      {{#if @showActions}}
        <div class="actions">
          <button {{on "click" @onEdit}}>Edit</button>
          <button {{on "click" @onDelete}}>Delete</button>
        </div>
      {{/if}}

      {{#if @showStats}}
        <div class="stats">
          <span>Posts: {{@user.postCount}}</span>
          <span>Followers: {{@user.followers}}</span>
        </div>
      {{/if}}
    </div>
  </template>
}

Correct: composable with named blocks

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

class UserCard extends Component {
  <template>
    <div class="user-card" ...attributes>
      {{#if (has-block "header")}}
        {{yield to="header"}}
      {{else}}
        <div class="header">
          <img src={{@user.avatar}} alt={{@user.name}} />
          <h3>{{@user.name}}</h3>
        </div>
      {{/if}}

      {{yield @user to="default"}}

      {{#if (has-block "actions")}}
        <div class="actions">
          {{yield @user to="actions"}}
        </div>
      {{/if}}

      {{#if (has-block "footer")}}
        <div class="footer">
          {{yield @user to="footer"}}
        </div>
      {{/if}}
    </div>
  </template>
}

Usage with flexible composition:

// app/components/user-list.gjs
import UserCard from './user-card';

<template>
  {{#each @users as |user|}}
    <UserCard @user={{user}}>
      <:header>
        <div class="custom-header">
          <span class="badge">{{user.role}}</span>
          <h3>{{user.name}}</h3>
        </div>
      </:header>

      <:default as |u|>
        <p class="bio">{{u.bio}}</p>
        <p class="email">{{u.email}}</p>
      </:default>

      <:actions as |u|>
        <button {{on "click" (fn @onEdit u)}}>Edit</button>
        <button {{on "click" (fn @onDelete u)}}>Delete</button>
      </:actions>

      <:footer as |u|>
        <div class="stats">
          Posts:
          {{u.postCount}}
          | Followers:
          {{u.followers}}
        </div>
      </:footer>
    </UserCard>
  {{/each}}
</template>

Contextual components pattern:

// app/components/data-table.gjs
import Component from '@glimmer/component';
import { hash } from '@ember/helper';

class HeaderCell extends Component {
  <template>
    <th class="sortable" {{on "click" @onSort}}>
      {{yield}}
      {{#if @sorted}}
        <span class="sort-icon">{{if @ascending "↑" "↓"}}</span>
      {{/if}}
    </th>
  </template>
}

class Row extends Component {
  <template>
    <tr class={{if @selected "selected"}}>
      {{yield}}
    </tr>
  </template>
}

class Cell extends Component {
  <template>
    <td>{{yield}}</td>
  </template>
}

class DataTable extends Component {
  <template>
    <table class="data-table">
      {{yield (hash Header=HeaderCell Row=Row Cell=Cell)}}
    </table>
  </template>
}

Using contextual components:

// app/components/users-table.gjs
import DataTable from './data-table';

<template>
  <DataTable as |Table|>
    <thead>
      <tr>
        <Table.Header @onSort={{fn @onSort "name"}}>Name</Table.Header>
        <Table.Header @onSort={{fn @onSort "email"}}>Email</Table.Header>
        <Table.Header @onSort={{fn @onSort "role"}}>Role</Table.Header>
      </tr>
    </thead>
    <tbody>
      {{#each @users as |user|}}
        <Table.Row @selected={{eq @selectedId user.id}}>
          <Table.Cell>{{user.name}}</Table.Cell>
          <Table.Cell>{{user.email}}</Table.Cell>
          <Table.Cell>{{user.role}}</Table.Cell>
        </Table.Row>
      {{/each}}
    </tbody>
  </DataTable>
</template>

Renderless component pattern:

// Usage
import Dropdown from './dropdown';

<template>
  <Dropdown as |dd|>
    <button {{on "click" dd.toggle}}>
      Menu
      {{if dd.isOpen "▲" "▼"}}
    </button>

    {{#if dd.isOpen}}
      <ul class="dropdown-menu">
        <li><a href="#" {{on "click" dd.close}}>Profile</a></li>
        <li><a href="#" {{on "click" dd.close}}>Settings</a></li>
        <li><a href="#" {{on "click" dd.close}}>Logout</a></li>
      </ul>
    {{/if}}
  </Dropdown>
</template>

Component composition provides flexibility, reusability, and clean separation of concerns while maintaining type safety and clarity.

Reference: https://guides.emberjs.com/release/components/block-content/

3.13 Use Glimmer Components Over Classic Components

Impact: HIGH (30-50% faster rendering)

Glimmer components are lighter, faster, and have a simpler lifecycle than classic Ember components. They don't have two-way bindings or element lifecycle hooks, making them more predictable and performant.

Incorrect: classic component

// app/components/user-card.js
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  tagName: 'div',
  classNames: ['user-card'],

  fullName: computed('user.{firstName,lastName}', function () {
    return `${this.user.firstName} ${this.user.lastName}`;
  }),

  didInsertElement() {
    this._super(...arguments);
    // Complex lifecycle management
  },
});

Correct: Glimmer component

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

class UserCard extends Component {
  get fullName() {
    return `${this.args.user.firstName} ${this.args.user.lastName}`;
  }

  <template>
    <div class="user-card">
      <h3>{{this.fullName}}</h3>
      <p>{{@user.email}}</p>
    </div>
  </template>
}

Glimmer components are 30-50% faster, have cleaner APIs, and integrate better with tracked properties.

Reference: https://guides.emberjs.com/release/components/component-state-and-actions/

3.14 Use Native Forms with Platform Validation

Impact: HIGH (Reduces JavaScript form complexity and improves built-in a11y)

Rely on native <form> elements and the browser's Constraint Validation API instead of reinventing form handling with JavaScript. The platform is really good at forms.

Over-engineering forms with JavaScript when native browser features provide validation, accessibility, and UX patterns for free.

Incorrect: Too much JavaScript

// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

class SignupForm extends Component {
  @tracked email = '';
  @tracked emailError = '';

  validateEmail = () => {
    // ❌ Reinventing email validation
    if (!this.email.includes('@')) {
      this.emailError = 'Invalid email';
    }
  };

  handleSubmit = (event) => {
    event.preventDefault();
    if (this.emailError) return;
    // Submit logic
  };

  <template>
    <div>
      <input
        type="text"
        value={{this.email}}
        {{on "input" this.updateEmail}}
        {{on "blur" this.validateEmail}}
      />
      {{#if this.emailError}}
        <span class="error">{{this.emailError}}</span>
      {{/if}}
      <button type="button" {{on "click" this.handleSubmit}}>Submit</button>
    </div>
  </template>
}

Use native <form> with proper input types and browser validation:

Correct: Native form with platform validation

// app/components/live-search.gjs - Controlled state needed for instant search
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';

class LiveSearch extends Component {
  @tracked query = '';

  updateQuery = (event) => {
    this.query = event.target.value;
    // Instant search as user types
    this.args.onSearch?.(this.query);
  };

  <template>
    {{! Controlled state justified - need instant feedback }}
    <input
      type="search"
      value={{this.query}}
      {{on "input" this.updateQuery}}
      placeholder="Search..."
    />
    {{#if this.query}}
      <p>Searching for: {{this.query}}</p>
    {{/if}}
  </template>
}

Performance: -15KB (no validation libraries needed)

Accessibility: +100% (native form semantics and error announcements)

Code: -50% (let the platform handle it)

Access and display native validation state in your component:

The browser provides rich validation state via input.validity:

For business logic validation beyond HTML5 constraints:

Use controlled patterns when you need real-time interactivity that isn't form submission:

Use controlled state when you need:

  • Real-time validation display as user types

  • Character counters

  • Live search/filtering

  • Multi-step forms where state drives UI

  • Form state that affects other components

Use native forms when:

3.15 Use Strict Mode and Template-Only Components

Impact: HIGH (Better type safety and simpler components)

Use strict mode and template-only components for simpler, safer code with better tooling support.

Incorrect: JavaScript component for simple templates

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

class UserCard extends Component {
  <template>
    <div class="user-card">
      <h3>{{@user.name}}</h3>
      <p>{{@user.email}}</p>
    </div>
  </template>
}

Correct: template-only component

// app/components/user-card.gjs
<template>
  <div class="user-card">
    <h3>{{@user.name}}</h3>
    <p>{{@user.email}}</p>
  </div>
</template>

With TypeScript for better type safety:

// app/components/user-card.gts
import type { TOC } from '@ember/component/template-only';

interface UserCardSignature {
  Args: {
    user: {
      name: string;
      email: string;
    };
  };
}

const UserCard: TOC<UserCardSignature> = <template>
  <div class="user-card">
    <h3>{{@user.name}}</h3>
    <p>{{@user.email}}</p>
  </div>
</template>;

export default UserCard;

Enable strict mode in your app:

// ember-cli-build.js
'use strict';

const EmberApp = require('ember-cli/lib/broccoli/ember-app');

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {
    'ember-cli-babel': {
      enableTypeScriptTransform: true,
    },
  });

  return app.toTree();
};

Template-only components are lighter, more performant, and easier to understand. Strict mode provides better error messages and prevents common mistakes.

Reference: https://guides.emberjs.com/release/upgrading/current-edition/templates/

3.16 Use Tracked Toolbox for Complex State

Impact: HIGH (Cleaner state management)

For complex state patterns like maps, sets, and arrays that need fine-grained reactivity, use tracked-toolbox utilities instead of marking entire structures as @tracked.

Incorrect: tracking entire structures

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

class TodoList extends Component {
  @tracked items = []; // Entire array replaced on every change

  addItem = (item) => {
    // Creates new array, invalidates all consumers
    this.items = [...this.items, item];
  };

  removeItem = (index) => {
    // Creates new array again
    this.items = this.items.filter((_, i) => i !== index);
  };
}

Correct: using tracked-toolbox

import Component from '@glimmer/component';
import { TrackedArray } from 'tracked-built-ins';

class TodoList extends Component {
  items = new TrackedArray([]);

  // Use arrow functions for methods used in templates (no @action needed)
  addItem = (item) => {
    // Efficiently adds to tracked array
    this.items.push(item);
  };

  removeItem = (index) => {
    // Efficiently removes from tracked array
    this.items.splice(index, 1);
  };
}

Also useful for Maps and Sets:

import { TrackedMap, TrackedSet } from 'tracked-built-ins';

class Cache extends Component {
  cache = new TrackedMap(); // Fine-grained reactivity per key
  selected = new TrackedSet(); // Fine-grained reactivity per item
}

tracked-built-ins provides fine-grained reactivity and better performance than replacing entire structures.

Reference: https://github.com/tracked-tools/tracked-built-ins

3.17 Validate Component Arguments

Impact: MEDIUM (Better error messages and type safety)

Validate component arguments for better error messages, documentation, and type safety.

Incorrect: no argument validation

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

class UserCard extends Component {
  <template>
    <div>
      <h3>{{@user.name}}</h3>
      <p>{{@user.email}}</p>
    </div>
  </template>
}

Correct: with TypeScript signature

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

interface UserCardSignature {
  Args: {
    user: {
      name: string;
      email: string;
      avatarUrl?: string;
    };
    onEdit?: (user: UserCardSignature['Args']['user']) => void;
  };
  Blocks: {
    default: [];
  };
  Element: HTMLDivElement;
}

class UserCard extends Component<UserCardSignature> {
  <template>
    <div ...attributes>
      <h3>{{@user.name}}</h3>
      <p>{{@user.email}}</p>

      {{#if @user.avatarUrl}}
        <img src={{@user.avatarUrl}} alt={{@user.name}} />
      {{/if}}

      {{#if @onEdit}}
        <button {{on "click" (fn @onEdit @user)}}>Edit</button>
      {{/if}}

      {{yield}}
    </div>
  </template>
}

Runtime validation with assertions: using getters

// app/components/data-table.gjs
import Component from '@glimmer/component';
import { assert } from '@ember/debug';

class DataTable extends Component {
  // Use getters so validation runs on each access and catches arg changes
  get columns() {
    assert(
      'DataTable requires @columns argument',
      this.args.columns && Array.isArray(this.args.columns),
    );

    assert(
      '@columns must be an array of objects with "key" and "label" properties',
      this.args.columns.every((col) => col.key && col.label),
    );

    return this.args.columns;
  }

  get rows() {
    assert('DataTable requires @rows argument', this.args.rows && Array.isArray(this.args.rows));

    return this.args.rows;
  }

  <template>
    <table>
      <thead>
        <tr>
          {{#each this.columns as |column|}}
            <th>{{column.label}}</th>
          {{/each}}
        </tr>
      </thead>
      <tbody>
        {{#each this.rows as |row|}}
          <tr>
            {{#each this.columns as |column|}}
              <td>{{get row column.key}}</td>
            {{/each}}
          </tr>
        {{/each}}
      </tbody>
    </table>
  </template>
}

Template-only component with TypeScript:

// app/components/icon.gts
import type { TOC } from '@ember/component/template-only';

interface IconSignature {
  Args: {
    name: string;
    size?: 'small' | 'medium' | 'large';
  };
  Element: HTMLSpanElement;
}

const Icon: TOC<IconSignature> = <template>
  <span ...attributes></span>
</template>;

export default Icon;

Documentation with JSDoc:

// app/components/modal.gjs
import Component from '@glimmer/component';

/**
 * Modal dialog component
 *
 * @param {Object} args
 * @param {boolean} args.isOpen - Controls modal visibility
 * @param {() => void} args.onClose - Called when modal should close
 * @param {string} [args.title] - Optional modal title
 * @param {string} [args.size='medium'] - Modal size: 'small', 'medium', 'large'
 */
class Modal extends Component {
  <template>
    {{#if @isOpen}}
      <div>
        {{#if @title}}
          <h2>{{@title}}</h2>
        {{/if}}
        {{yield}}
        <button {{on "click" @onClose}}>Close</button>
      </div>
    {{/if}}
  </template>
}

Argument validation provides better error messages during development, serves as documentation, and enables better IDE support.

Reference: https://guides.emberjs.com/release/typescript/


4. Accessibility Best Practices

Impact: HIGH

Making applications accessible is critical. Use ember-a11y-testing, semantic HTML, proper ARIA attributes, and keyboard navigation support.

4.1 Announce Route Transitions to Screen Readers

Impact: HIGH (Critical for screen reader navigation)

Announce page title changes and route transitions to screen readers so users know when navigation has occurred.

Incorrect: no announcements

// app/router.js
export default class Router extends EmberRouter {
  location = config.locationType;
  rootURL = config.rootURL;
}

Correct: using a11y-announcer library - recommended

// app/routes/dashboard.gjs
import { pageTitle } from 'ember-page-title';

<template>
  {{pageTitle "Dashboard"}}

  <div class="dashboard">
    {{outlet}}
  </div>
</template>

Use the a11y-announcer library for robust route announcements:

The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:

Alternative: DIY approach with ARIA live regions:

If you prefer not to use a library, you can implement route announcements yourself:

Alternative: Use ember-page-title with announcements:

Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.

Reference: https://guides.emberjs.com/release/accessibility/page-template-considerations/

4.2 Form Labels and Error Announcements

Impact: HIGH (Essential for screen reader users)

All form inputs must have associated labels, and validation errors should be announced to screen readers using ARIA live regions.

Incorrect: missing labels and announcements

// app/components/form.gjs
<template>
  <form {{on "submit" this.handleSubmit}}>
    <input type="email" value={{this.email}} {{on "input" this.updateEmail}} placeholder="Email" />

    {{#if this.emailError}}
      <span>{{this.emailError}}</span>
    {{/if}}

    <button type="submit">Submit</button>
  </form>
</template>

Correct: with labels and announcements

// app/components/form.gjs
<template>
  <form {{on "submit" this.handleSubmit}}>
    <div>
      <label for="email-input">
        Email Address
        {{#if this.isEmailRequired}}
          <span aria-label="required">*</span>
        {{/if}}
      </label>

      <input
        id="email-input"
        type="email"
        value={{this.email}}
        {{on "input" this.updateEmail}}
        aria-describedby={{if this.emailError "email-error"}}
        aria-invalid={{if this.emailError "true"}}
        required={{this.isEmailRequired}}
      />

      {{#if this.emailError}}
        <span id="email-error" role="alert" aria-live="polite">
          {{this.emailError}}
        </span>
      {{/if}}
    </div>

    <button type="submit" disabled={{this.isSubmitting}}>
      {{#if this.isSubmitting}}
        <span aria-live="polite">Submitting...</span>
      {{else}}
        Submit
      {{/if}}
    </button>
  </form>
</template>

For complex forms, use platform-native validation with custom logic:

// app/components/user-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';

class UserForm extends Component {
  @tracked errorMessages = {};

  validateEmail = (event) => {
    // Custom business logic validation
    const input = event.target;
    const value = input.value;

    if (!value) {
      input.setCustomValidity('Email is required');
      return false;
    }

    if (!input.validity.valid) {
      input.setCustomValidity('Must be a valid email');
      return false;
    }

    // Additional custom validation (e.g., check if email is already taken)
    if (value === 'taken@example.com') {
      input.setCustomValidity('This email is already registered');
      return false;
    }

    input.setCustomValidity('');
    return true;
  };

  handleSubmit = async (event) => {
    event.preventDefault();
    const form = event.target;

    // Run custom validations
    const emailInput = form.querySelector('[name="email"]');
    const fakeEvent = { target: emailInput };
    this.validateEmail(fakeEvent);

    // Use native validation check
    if (!form.checkValidity()) {
      form.reportValidity();
      return;
    }

    const formData = new FormData(form);
    await this.args.onSubmit(formData);
  };

  <template>
    <form {{on "submit" this.handleSubmit}}>
      <label for="user-email">
        Email
        <input
          id="user-email"
          type="email"
          name="email"
          required
          value={{@user.email}}
          {{on "blur" this.validateEmail}}
        />
      </label>
      <button type="submit">Save</button>
    </form>
  </template>
}

Always associate labels with inputs and announce dynamic changes to screen readers using aria-live regions.

Reference: https://guides.emberjs.com/release/accessibility/application-considerations/

4.3 Keyboard Navigation Support

Impact: HIGH (Critical for keyboard-only users)

Ensure all interactive elements are keyboard accessible and focus management is handled properly, especially in modals and dynamic content.

Incorrect: no keyboard support

// app/components/dropdown.gjs
<template>
  <div class="dropdown" {{on "click" this.toggleMenu}}>
    Menu
    {{#if this.isOpen}}
      <div class="dropdown-menu">
        <div {{on "click" this.selectOption}}>Option 1</div>
        <div {{on "click" this.selectOption}}>Option 2</div>
      </div>
    {{/if}}
  </div>
</template>

Correct: full keyboard support with custom modifier

// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import focusFirst from '../modifiers/focus-first';

class Dropdown extends Component {
  @tracked isOpen = false;

  @action
  toggleMenu() {
    this.isOpen = !this.isOpen;
  }

  @action
  handleButtonKeyDown(event) {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      this.isOpen = true;
    }
  }

  @action
  handleMenuKeyDown(event) {
    if (event.key === 'Escape') {
      this.isOpen = false;
      // Return focus to button
      event.target.closest('.dropdown').querySelector('button').focus();
    }
    // Handle arrow key navigation between menu items
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      this.moveFocus(event.key === 'ArrowDown' ? 1 : -1);
    }
  }

  moveFocus(direction) {
    const items = Array.from(document.querySelectorAll('[role="menuitem"] button'));
    const currentIndex = items.indexOf(document.activeElement);
    const nextIndex = (currentIndex + direction + items.length) % items.length;
    items[nextIndex]?.focus();
  }

  @action
  selectOption(value) {
    this.args.onSelect?.(value);
    this.isOpen = false;
  }

  <template>
    <div class="dropdown">
      <button
        type="button"
        {{on "click" this.toggleMenu}}
        {{on "keydown" this.handleButtonKeyDown}}
        aria-haspopup="true"
        aria-expanded="{{this.isOpen}}"
      >
        Menu
      </button>

      {{#if this.isOpen}}
        <ul
          class="dropdown-menu"
          role="menu"
          {{focusFirst '[role="menuitem"] button'}}
          {{on "keydown" this.handleMenuKeyDown}}
        >
          <li role="menuitem">
            <button type="button" {{on "click" (fn this.selectOption "1")}}>
              Option 1
            </button>
          </li>
          <li role="menuitem">
            <button type="button" {{on "click" (fn this.selectOption "2")}}>
              Option 2
            </button>
          </li>
        </ul>
      {{/if}}
    </div>
  </template>
}

For focus trapping in modals, use ember-focus-trap:

npm install @fluentui/keyboard-keys

Alternative: Use libraries for keyboard support:

For complex keyboard interactions, consider using libraries that abstract keyboard support patterns:

Or use tabster for comprehensive keyboard navigation management including focus trapping, arrow key navigation, and modalizers.

Proper keyboard navigation ensures all users can interact with your application effectively.

Reference: https://guides.emberjs.com/release/accessibility/keyboard/

4.4 Semantic HTML and ARIA Attributes

Impact: HIGH (Essential for screen reader users)

Use semantic HTML elements and proper ARIA attributes to make your application accessible to screen reader users. The first rule of ARIA is to not use ARIA - prefer native semantic HTML elements whenever possible.

Key principle: Native HTML elements have built-in keyboard support, roles, and behaviors. Only add ARIA when semantic HTML can't provide the needed functionality.

Incorrect: divs with insufficient semantics

// app/components/example.gjs
<template>
  <div class="button" {{on "click" this.submit}}>
    Submit
  </div>

  <div class="nav">
    <div class="nav-item">Home</div>
    <div class="nav-item">About</div>
  </div>

  <div class="alert">
    {{this.message}}
  </div>
</template>

Correct: semantic HTML with proper ARIA

// app/components/example.gjs
import { LinkTo } from '@ember/routing';

<template>
  <button type="submit" {{on "click" this.submit}}>
    Submit
  </button>

  <nav aria-label="Main navigation">
    <ul>
      <li><LinkTo @route="index">Home</LinkTo></li>
      <li><LinkTo @route="about">About</LinkTo></li>
    </ul>
  </nav>

  <div role="alert" aria-live="polite" aria-atomic="true">
    {{this.message}}
  </div>
</template>

For interactive custom elements:

// app/components/custom-button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import XIcon from './x-icon';

class CustomButton extends Component {
  @action
  handleKeyDown(event) {
    // Support Enter and Space keys for keyboard users
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.handleClick();
    }
  }

  @action
  handleClick() {
    this.args.onClick?.();
  }

  <template>
    <div
      role="button"
      tabindex="0"
      {{on "click" this.handleClick}}
      {{on "keydown" this.handleKeyDown}}
      aria-label="Close dialog"
    >
      <XIcon />
    </div>
  </template>
}

Always use native semantic elements when possible. When creating custom interactive elements, ensure they're keyboard accessible and have proper ARIA attributes.

References:

4.5 Use ember-a11y-testing for Automated Checks

Impact: HIGH (Catch 30-50% of a11y issues automatically)

Integrate ember-a11y-testing into your test suite to automatically catch common accessibility violations during development. This addon uses axe-core to identify issues before they reach production.

Incorrect: no accessibility testing

// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import UserForm from 'my-app/components/user-form';

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

  test('it submits the form', async function (assert) {
    await render(<template><UserForm /></template>);
    await fillIn('input', 'John');
    await click('button');
    assert.ok(true);
  });
});

Correct: with a11y testing

// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import UserForm from 'my-app/components/user-form';

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

  test('it submits the form', async function (assert) {
    await render(<template><UserForm /></template>);

    // Automatically checks for a11y violations
    await a11yAudit();

    await fillIn('input', 'John');
    await click('button');
    assert.ok(true);
  });
});

Setup: install and configure

// tests/test-helper.js
import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';

setupGlobalA11yHooks(); // Runs on every test automatically

ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.

Reference: https://github.com/ember-a11y/ember-a11y-testing


5. Service and State Management

Impact: MEDIUM-HIGH

Efficient service patterns, proper dependency injection, and state management reduce redundant computations and API calls.

5.1 Cache API Responses in Services

Impact: MEDIUM-HIGH (50-90% reduction in duplicate requests)

Cache API responses in services to avoid duplicate network requests. Use tracked properties to make the cache reactive.

Incorrect: no caching

// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';

export default class UserService extends Service {
  @service store;

  async getCurrentUser() {
    // Fetches from API every time
    return this.store.request({ url: '/users/me' });
  }
}

Correct: with caching

// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedMap } from 'tracked-built-ins';

export default class UserService extends Service {
  @service store;

  @tracked currentUser = null;
  cache = new TrackedMap();

  async getCurrentUser() {
    if (!this.currentUser) {
      const response = await this.store.request({ url: '/users/me' });
      this.currentUser = response.content.data;
    }
    return this.currentUser;
  }

  async getUser(id) {
    if (!this.cache.has(id)) {
      const response = await this.store.request({ url: `/users/${id}` });
      this.cache.set(id, response.content.data);
    }
    return this.cache.get(id);
  }

  clearCache() {
    this.currentUser = null;
    this.cache.clear();
  }
}

For time-based cache invalidation:

import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class DataService extends Service {
  @tracked _cache = null;
  _cacheTimestamp = null;
  _cacheDuration = 5 * 60 * 1000; // 5 minutes

  async getData() {
    const now = Date.now();
    const isCacheValid =
      this._cache && this._cacheTimestamp && now - this._cacheTimestamp < this._cacheDuration;

    if (!isCacheValid) {
      this._cache = await this.fetchData();
      this._cacheTimestamp = now;
    }

    return this._cache;
  }

  async fetchData() {
    const response = await fetch('/api/data');
    return response.json();
  }
}

Caching in services prevents duplicate API requests and improves performance significantly.

5.2 Implement Robust Data Requesting Patterns

Impact: HIGH (Prevents request waterfalls and race conditions in data flows)

Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic.

export default in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid .gjs/.hbs codebases, you can pair named exports with a default alias where needed.

Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests.

Incorrect:

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

export default class DashboardRoute extends Route {
  async model() {
    // Sequential waterfall - slow!
    const user = await this.store.request({ url: '/users/me' });
    const posts = await this.store.request({ url: '/posts' });
    const notifications = await this.store.request({ url: '/notifications' });

    // No error handling
    // No cancellation
    return { user, posts, notifications };
  }
}

Use RSVP.hash or Promise.all for parallel loading:

Correct: parallelized model loading

// app/services/batch-loader.js
import Service, { service } from '@ember/service';

export default class BatchLoaderService extends Service {
  @service store;

  pendingIds = new Set();
  batchTimeout = null;

  async loadUser(id) {
    this.pendingIds.add(id);

    if (!this.batchTimeout) {
      this.batchTimeout = setTimeout(() => this.executeBatch(), 50);
    }

    // Return a promise that resolves when batch completes
    return new Promise((resolve) => {
      this.registerCallback(id, resolve);
    });
  }

  async executeBatch() {
    const ids = Array.from(this.pendingIds);
    this.pendingIds.clear();
    this.batchTimeout = null;

    const response = await this.store.request({
      url: `/users?ids=${ids.join(',')}`,
    });

    // Resolve all pending promises
    response.content.forEach((user) => {
      this.resolveCallback(user.id, user);
    });
  }
}

Handle errors gracefully with fallbacks:

Prevent race conditions by canceling stale requests:

For non-ember-concurrency scenarios:

When requests depend on previous results:

For real-time data updates:

Optimize multiple similar requests:

  • Parallel requests (RSVP.hash): 60-80% faster than sequential

  • Request cancellation: Prevents memory leaks and race conditions

  • Retry logic: Improves reliability with < 5% overhead

  • Batch loading: 40-70% reduction in requests

  • RSVP.hash: Independent data that can load in parallel

  • ember-concurrency: Search, autocomplete, or user-driven requests

  • AbortController: Long-running requests that may become stale

  • Retry logic: Critical data with transient network issues

  • Batch loading: Loading many similar items (N+1 scenarios)

  • WarpDrive Documentation

  • ember-concurrency

  • RSVP.js

  • AbortController MDN

5.3 Manage Service Owner and Linkage Patterns

Impact: MEDIUM-HIGH (Better service organization and dependency management)

Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.

Incorrect: manual service instantiation

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import ApiService from '../services/api';

class UserProfile extends Component {
  // ❌ Creates orphaned instance without owner
  api = new ApiService();

  async loadUser() {
    // Won't have access to other services or owner features
    return this.api.fetch('/user/me');
  }

  <template>
    <div>{{@user.name}}</div>
  </template>
}

Correct: proper service injection with owner

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';

class UserProfile extends Component {
  // ✅ Proper injection with owner linkage
  @service api;

  async loadUser() {
    // Has full owner context and can inject other services
    return this.api.fetch('/user/me');
  }

  <template>
    <div>{{@user.name}}</div>
  </template>
}

Creating instances with owner:

// app/components/data-processor.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { service } from '@ember/service';

class DataTransformer {
  @service store;

  transform(data) {
    // Can use injected services because it has an owner
    return this.store.request({ url: '/transform', data });
  }
}

class DataProcessor extends Component {
  @service('store') storeService;

  constructor(owner, args) {
    super(owner, args);

    // Manual instantiation with owner linkage
    this.transformer = new DataTransformer();
    setOwner(this.transformer, getOwner(this));
  }

  processData(data) {
    // transformer can now access services
    return this.transformer.transform(data);
  }

  <template>
    <div>Processing...</div>
  </template>
}

Factory pattern with owner:

// Usage in component
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { createLogger } from '../utils/logger-factory';

class My extends Component {
  logger = createLogger(getOwner(this), 'MyComponent');

  performAction() {
    this.logger.log('Action performed');
  }

  <template>
    <button {{on "click" this.performAction}}>Do Something</button>
  </template>
}

Using reactiveweb's link() for ownership and destruction:

// app/components/advanced-form.gjs
import Component from '@glimmer/component';
import { link } from 'reactiveweb/link';

class ValidationService {
  validate(data) {
    // Validation logic
    return data.email && data.email.includes('@');
  }
}

class FormStateManager {
  data = { email: '' };

  updateEmail(value) {
    this.data.email = value;
  }
}

export class AdvancedForm extends Component {
  // link() handles both owner and destruction automatically
  validation = link(this, () => new ValidationService());
  formState = link(this, () => new FormStateManager());

  get isValid() {
    return this.validation.validate(this.formState.data);
  }

  <template>
    <form>
      <input value={{this.formState.data.email}} />
      {{#if (not this.isValid)}}
        <span>Invalid form</span>
      {{/if}}
    </form>
  </template>
}

The link() function from reactiveweb provides both ownership transfer and automatic destruction linkage.

Why use link():

Using createService from ember-primitives:

// app/components/analytics-tracker.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { createService } from 'ember-primitives/utils';

// Define service logic as a plain function
function AnalyticsService() {
  let events = [];

  return {
    get events() {
      return events;
    },

    track(event) {
      events.push({ ...event, timestamp: Date.now() });

      // Send to analytics endpoint
      fetch('/analytics', {
        method: 'POST',
        body: JSON.stringify(event),
      });
    },
  };
}

export class AnalyticsTracker extends Component {
  // createService handles owner linkage and cleanup automatically
  analytics = createService(this, AnalyticsService);

  <template>
    <div>Tracking {{this.analytics.events.length}} events</div>
  </template>
}

Why createService:

Co-located services with components:

// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { CartService } from './service';

class ShoppingCart extends Component {
  cart = (() => {
    const instance = new CartService();
    setOwner(instance, getOwner(this));
    return instance;
  })();

  <template>
    <div class="cart">
      <h3>Cart ({{this.cart.items.length}} items)</h3>
      <div>Total: ${{this.cart.total}}</div>

      {{#each this.cart.items as |item|}}
        <div class="cart-item">
          {{item.name}}
          - ${{item.price}}
          <button {{on "click" (fn this.cart.removeItem item.id)}}>
            Remove
          </button>
        </div>
      {{/each}}

      <button {{on "click" this.cart.clear}}>Clear Cart</button>
    </div>
  </template>
}

Service-like utilities in utils/ directory:

// app/components/notification-container.gjs
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { NotificationManager } from '../utils/notification-manager';

class NotificationContainer extends Component {
  notifications = new NotificationManager(getOwner(this));

  <template>
    <div class="notifications">
      {{#each this.notifications.notifications as |notif|}}
        <div class="notification notification-{{notif.type}}">
          {{notif.message}}
          <button {{on "click" (fn this.notifications.dismiss notif.id)}}>
            ×
          </button>
        </div>
      {{/each}}
    </div>

    {{! Example usage }}
    <button {{on "click" (fn this.notifications.add "Success!" "success")}}>
      Show Notification
    </button>
  </template>
}

Runtime service registration:

// app/instance-initializers/dynamic-services.js
export function initialize(appInstance) {
  // Register service dynamically without app/services file
  appInstance.register(
    'service:feature-flags',
    class FeatureFlagsService {
      flags = {
        newDashboard: true,
        betaFeatures: false,
      };

      isEnabled(flag) {
        return this.flags[flag] || false;
      }
    },
  );

  // Make it a singleton
  appInstance.inject('route', 'featureFlags', 'service:feature-flags');
  appInstance.inject('component', 'featureFlags', 'service:feature-flags');
}

export default {
  initialize,
};

Using registered services:

// app/components/feature-gated.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';

class FeatureGated extends Component {
  @service featureFlags;

  get shouldShow() {
    return this.featureFlags.isEnabled(this.args.feature);
  }

  <template>
    {{#if this.shouldShow}}
      {{yield}}
    {{else}}
      <div class="feature-disabled">This feature is not available</div>
    {{/if}}
  </template>
}
  1. Use @service decorator for app/services - cleanest and most maintainable

  2. Use link() from reactiveweb for ownership and destruction linkage

  3. Use createService from ember-primitives for component-scoped services without extending Service class

  4. Manual owner passing for utilities that need occasional service access

  5. Co-located services for component-specific state that doesn't need global access

  6. Runtime registration for dynamic services or testing scenarios

  7. Always use setOwner when manually instantiating classes that need services

  • app/services: Global singletons needed across the app

  • link() from reactiveweb: When you need both owner and destruction linkage

  • createService from ember-primitives: Component-scoped services without Service class

  • Co-located services: Component-specific state, not needed elsewhere

  • Utils with owner: Stateless utilities that occasionally need config/services

  • Runtime registration: Dynamic configuration, feature flags, testing

Reference: https://api.emberjs.com/ember/release/functions/@ember%2Fapplication/getOwner, https://guides.emberjs.com/release/applications/dependency-injection/, https://reactive.nullvoxpopuli.com/functions/link.link.html, https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md

5.4 Optimize WarpDrive Queries

Impact: MEDIUM-HIGH (40-70% reduction in API calls)

Use WarpDrive's request features effectively to reduce API calls and load only the data you need.

Incorrect: multiple queries, overfetching

// app/routes/posts.js
export default class PostsRoute extends Route {
  @service store;

  async model() {
    // Loads all posts (could be thousands)
    const response = await this.store.request({ url: '/posts' });
    const posts = response.content.data;

    // Then filters in memory
    return posts.filter((post) => post.attributes.status === 'published');
  }
}

Correct: filtered query with pagination

// app/routes/posts.js
export default class PostsRoute extends Route {
  @service store;

  queryParams = {
    page: { refreshModel: true },
    filter: { refreshModel: true },
  };

  model(params) {
    // Server-side filtering and pagination
    return this.store.request({
      url: '/posts',
      data: {
        filter: {
          status: 'published',
        },
        page: {
          number: params.page || 1,
          size: 20,
        },
        include: 'author', // Sideload related data
        fields: {
          // Sparse fieldsets
          posts: 'title,excerpt,publishedAt,author',
          users: 'name,avatar',
        },
      },
    });
  }
}

Use request with includes for single records:

// app/routes/post.js
export default class PostRoute extends Route {
  @service store;

  model(params) {
    return this.store.request({
      url: `/posts/${params.post_id}`,
      data: {
        include: 'author,comments.user', // Nested relationships
      },
    });
  }
}

For frequently accessed data, use cache lookups:

// app/components/user-badge.js
class UserBadge extends Component {
  @service store;

  get user() {
    // Check cache first, avoiding API call if already loaded
    const cached = this.store.cache.peek({
      type: 'user',
      id: this.args.userId,
    });

    if (cached) {
      return cached;
    }

    // Only fetch if not in cache
    return this.store.request({
      url: `/users/${this.args.userId}`,
    });
  }
}

Use request options for custom queries:

model() {
  return this.store.request({
    url: '/posts',
    data: {
      include: 'author,tags',
      customParam: 'value'
    },
    options: {
      reload: true // Bypass cache
    }
  });
}

Efficient WarpDrive usage reduces network overhead and improves application performance significantly.

Reference: https://warp-drive.io/

5.5 Use Services for Shared State

Impact: MEDIUM-HIGH (Better state management and reusability)

Use services to manage shared state across components and routes instead of passing data through multiple layers or duplicating state.

Incorrect: prop drilling

// app/routes/dashboard.gjs
export default class DashboardRoute extends Route {
  model() {
    return { currentTheme: 'dark' };
  }

  <template>
    <Header @theme={{@model.currentTheme}} />
    <Sidebar @theme={{@model.currentTheme}} />
    <MainContent @theme={{@model.currentTheme}} />
  </template>
}

Correct: using service

// app/components/sidebar.js
import Component from '@glimmer/component';
import { service } from '@ember/service';

class Sidebar extends Component {
  @service theme;

  // Access theme.currentTheme directly
}

Services provide centralized state management with automatic reactivity through tracked properties.

For complex state, consider using Ember Data or ember-orbit:

// app/services/cart.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedArray } from 'tracked-built-ins';
import { cached } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class CartService extends Service {
  @service store;

  items = new TrackedArray([]);

  @cached
  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  @cached
  get itemCount() {
    return this.items.length;
  }

  @action
  addItem(item) {
    this.items.push(item);
  }

  @action
  removeItem(item) {
    const index = this.items.indexOf(item);
    if (index > -1) {
      this.items.splice(index, 1);
    }
  }
}

Reference: https://guides.emberjs.com/release/services/


6. Template Optimization

Impact: MEDIUM

Optimizing templates with proper helpers, avoiding expensive computations in templates, and using {{#each}} efficiently improves rendering speed.

6.1 Avoid Heavy Computation in Templates

Impact: MEDIUM (40-60% reduction in render time)

Move expensive computations from templates to cached getters in the component class or in-scope functions for template-only components. Templates should only display data, not compute it. Keep templates easy for humans to read by minimizing nested function invocations.

Why this matters:

  • Templates should be easy to read and understand

  • Nested function calls create cognitive overhead

  • Computations should be cached and reused, not recalculated on every render

  • Template-only components (without this) need alternative patterns

Incorrect: heavy computation in template

// app/components/stats.gjs
import { sum, map, div, max, multiply, sortBy } from '../helpers/math';

<template>
  <div>
    <p>Total: {{sum (map @items "price")}}</p>
    <p>Average: {{div (sum (map @items "price")) @items.length}}</p>
    <p>Max: {{max (map @items "price")}}</p>

    {{#each (sortBy "name" @items) as |item|}}
      <div>{{item.name}}: {{multiply item.price item.quantity}}</div>
    {{/each}}
  </div>
</template>

Correct: computation in component with cached getters

// app/components/stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';

export class Stats extends Component {
  // @cached is useful when getters are accessed multiple times
  // For single access, regular getters are sufficient

  @cached
  get total() {
    return this.args.items.reduce((sum, item) => sum + item.price, 0);
  }

  get average() {
    // No @cached needed if only accessed once in template
    return this.args.items.length > 0 ? this.total / this.args.items.length : 0;
  }

  get maxPrice() {
    return Math.max(...this.args.items.map((item) => item.price));
  }

  @cached
  get sortedItems() {
    // @cached useful here as it's used by itemsWithTotal
    return [...this.args.items].sort((a, b) => a.name.localeCompare(b.name));
  }

  @cached
  get itemsWithTotal() {
    // @cached useful as accessed multiple times in {{#each}}
    return this.sortedItems.map((item) => ({
      ...item,
      total: item.price * item.quantity,
    }));
  }

  <template>
    <div>
      <p>Total: {{this.total}}</p>
      <p>Average: {{this.average}}</p>
      <p>Max: {{this.maxPrice}}</p>

      {{#each this.itemsWithTotal key="id" as |item|}}
        <div>{{item.name}}: {{item.total}}</div>
      {{/each}}
    </div>
  </template>
}

Note on @cached: Use @cached when a getter is accessed multiple times (like in {{#each}} loops or by other getters). For getters accessed only once, regular getters are sufficient and avoid unnecessary memoization overhead.

Moving computations to getters ensures they run only when dependencies change, not on every render. Templates remain clean and readable.

6.2 Compose Helpers for Reusable Logic

Impact: MEDIUM-HIGH (Better code reuse and testability)

Compose helpers to create reusable, testable logic that can be combined in templates and components.

Incorrect: logic duplicated in templates

// app/components/user-profile.gjs
<template>
  <div class="profile">
    <h1>{{uppercase (truncate @user.name 20)}}</h1>

    {{#if (and @user.isActive (not @user.isDeleted))}}
      <span class="status">Active</span>
    {{/if}}

    <p>{{lowercase @user.email}}</p>

    {{#if (gt @user.posts.length 0)}}
      <span>Posts: {{@user.posts.length}}</span>
    {{/if}}
  </div>
</template>

Correct: composed helpers

// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
import { isVisibleUser } from '../helpers/is-visible-user';
import { formatEmail } from '../helpers/format-email';

<template>
  <div class="profile">
    <h1>{{displayName @user.name}}</h1>

    {{#if (isVisibleUser @user)}}
      <span class="status">Active</span>
    {{/if}}

    <p>{{formatEmail @user.email}}</p>

    {{#if (gt @user.posts.length 0)}}
      <span>Posts: {{@user.posts.length}}</span>
    {{/if}}
  </div>
</template>

Functional composition with pipe helper:

// app/helpers/pipe.js
export function pipe(...fns) {
  return (value) => fns.reduce((acc, fn) => fn(acc), value);
}

Or use a compose helper:

// app/helpers/compose.js
export function compose(...helperFns) {
  return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}

Usage:

// app/components/text-processor.gjs
import { fn } from '@ember/helper';

// Individual helpers
const uppercase = (str) => str?.toUpperCase() || '';
const trim = (str) => str?.trim() || '';
const truncate = (str, length = 20) => str?.slice(0, length) || '';

<template>
  {{! Compose multiple transformations }}
  <div>
    {{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
  </div>
</template>

Higher-order helpers:

// Usage in template
import { mapBy } from '../helpers/map-by';
import { partialApply } from '../helpers/partial-apply';

<template>
  {{! Extract property from array }}
  <ul>
    {{#each (mapBy @users "name") as |name|}}
      <li>{{name}}</li>
    {{/each}}
  </ul>

  {{! Partial application }}
  {{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
    <span>Price: {{formatTwoDecimals @price}}</span>
  {{/let}}
</template>

Chainable transformation helpers:

// Usage
import { transform } from '../helpers/transform';

function filter(items) {
  return items
    .filter((item) => item.active)
    .sort((a, b) => a.name.localeCompare(b.name))
    .take(10).result;
}

<template>
  {{#let (transform @items) as |t|}}
    {{#each (filter t) as |item|}}
      <div>{{item.name}}</div>
    {{/each}}
  {{/let}}
</template>

Conditional composition:

// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
  return !condition ? falseFn() : trueFn ? trueFn() : null;
}

Testing composed helpers:

// tests/helpers/display-name-test.js
import { module, test } from 'qunit';
import { displayName } from 'my-app/helpers/display-name';

module('Unit | Helper | display-name', function () {
  test('it formats name correctly', function (assert) {
    assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
  });

  test('it truncates long names', function (assert) {
    assert.strictEqual(
      displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
      'A VERY LON...',
    );
  });

  test('it handles null', function (assert) {
    assert.strictEqual(displayName(null), '');
  });
});

Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.

Reference: https://guides.emberjs.com/release/components/helper-functions/

6.3 Import Helpers Directly in Templates

Impact: MEDIUM (Better tree-shaking and clarity)

Import helpers directly in gjs/gts files for better tree-shaking, clearer dependencies, and improved type safety.

Incorrect: global helper resolution

// app/components/user-profile.gjs
<template>
  <div class="profile">
    <h1>{{capitalize @user.name}}</h1>
    <p>Joined: {{format-date @user.createdAt}}</p>
    <p>Posts: {{pluralize @user.postCount "post"}}</p>
  </div>
</template>

Correct: explicit helper imports

// app/components/user-profile.gjs
import { capitalize } from 'ember-string-helpers';
import { formatDate } from 'ember-intl';
import { pluralize } from 'ember-inflector';

<template>
  <div class="profile">
    <h1>{{capitalize @user.name}}</h1>
    <p>Joined: {{formatDate @user.createdAt}}</p>
    <p>Posts: {{pluralize @user.postCount "post"}}</p>
  </div>
</template>

Built-in and library helpers:

// app/components/conditional-content.gjs
import { fn, hash } from '@ember/helper'; // Actually built-in to Ember
import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon

<template>
  <div class="content">
    {{#if (eq @status "active")}}
      <span class="badge">Active</span>
    {{/if}}

    {{#if (not @isLoading)}}
      <button {{on "click" (fn @onSave (hash id=@id data=@data))}}>
        Save
      </button>
    {{/if}}
  </div>
</template>

Custom helper with imports:

// app/components/price-display.gjs
import { formatCurrency } from '../utils/format-currency';

<template>
  <div class="price">
    {{formatCurrency @amount currency="EUR"}}
  </div>
</template>

Type-safe helpers with TypeScript:

// app/components/typed-component.gts
import { fn } from '@ember/helper';
import type { TOC } from '@ember/component/template-only';

interface Signature {
  Args: {
    items: Array<{ id: string; name: string }>;
    onSelect: (id: string) => void;
  };
}

const TypedComponent: TOC<Signature> = <template>
  <ul>
    {{#each @items as |item|}}
      <li {{on "click" (fn @onSelect item.id)}}>
        {{item.name}}
      </li>
    {{/each}}
  </ul>
</template>;

export default TypedComponent;

Explicit helper imports enable better tree-shaking, make dependencies clear, and improve IDE support with proper type checking.

Reference: https://github.com/ember-template-imports/ember-template-imports

6.4 No helper() Wrapper for Plain Functions

Impact: LOW-MEDIUM (Simpler code, better performance)

In modern Ember, plain functions can be used directly as helpers without wrapping them with helper(). The helper() wrapper is legacy and adds unnecessary complexity.

Incorrect (using helper() wrapper):

// app/utils/format-date.js
import { helper } from '@ember/component/helper';

function formatDate([date]) {
  return new Date(date).toLocaleDateString();
}

export default helper(formatDate);

Correct: plain function

// app/utils/format-date.js
export function formatDate(date) {
  return new Date(date).toLocaleDateString();
}

Usage in templates:

// app/components/post-card.gjs
import { formatDate } from '../utils/format-date';

<template>
  <article>
    <h2>{{@post.title}}</h2>
    <time>{{formatDate @post.publishedAt}}</time>
  </article>
</template>

With Multiple Arguments:

// app/components/price.gjs
import { formatCurrency } from '../utils/format-currency';

<template>
  <span class="price">
    {{formatCurrency @amount @currency}}
  </span>
</template>

For Helpers that Need Services: use class-based

// app/utils/format-relative-time.js
export class FormatRelativeTime {
  constructor(owner) {
    this.intl = owner.lookup('service:intl');
  }

  compute(date) {
    return this.intl.formatRelative(date);
  }
}

When you need dependency injection, use a class instead of helper():

Why Avoid helper():

  1. Simpler: Plain functions are easier to understand

  2. Standard JavaScript: No Ember-specific wrapper needed

  3. Better Testing: Plain functions are easier to test

  4. Performance: No wrapper overhead

  5. Modern Pattern: Aligns with modern Ember conventions

Migration from helper():

// Before
import { helper } from '@ember/component/helper';

function capitalize([text]) {
  return text.charAt(0).toUpperCase() + text.slice(1);
}

export default helper(capitalize);

// After
export function capitalize(text) {
  return text.charAt(0).toUpperCase() + text.slice(1);
}

Common Helper Patterns:

// Usage
import { capitalize, truncate, pluralize } from '../utils/string-helpers';

<template>
  <h1>{{capitalize @title}}</h1>
  <p>{{truncate @description 100}}</p>
  <span>{{@count}} {{pluralize @count "item" "items"}}</span>
</template>

Plain functions are the modern way to create helpers in Ember. Only use classes when you need dependency injection.

Reference: https://guides.emberjs.com/release/components/helper-functions/

6.5 Optimize Conditional Rendering

Impact: HIGH (Reduces unnecessary rerenders in dynamic template branches)

Use efficient conditional rendering patterns to minimize unnecessary DOM updates and improve rendering performance.

Inefficient conditional logic causes excessive re-renders, creates complex template code, and can lead to poor performance in lists and dynamic UIs.

Incorrect:

// app/components/user-list.gjs
import Component from '@glimmer/component';

class UserList extends Component {
  <template>
    {{#each @users as |user|}}
      <div class="user">
        {{! Recomputes every time}}
        {{#if (eq user.role "admin")}}
          <span class="badge admin">{{user.name}} (Admin)</span>
        {{/if}}
        {{#if (eq user.role "moderator")}}
          <span class="badge mod">{{user.name}} (Mod)</span>
        {{/if}}
        {{#if (eq user.role "user")}}
          <span>{{user.name}}</span>
        {{/if}}
      </div>
    {{/each}}
  </template>
}

Use {{#if}} / {{#else if}} / {{#else}} chains and extract computed logic to getters for better performance and readability.

Correct:

// app/components/task-list.gjs
import Component from '@glimmer/component';

class TaskList extends Component {
  get hasTasks() {
    return this.args.tasks?.length > 0;
  }

  <template>
    {{#if this.hasTasks}}
      <ul class="task-list">
        {{#each @tasks as |task|}}
          <li>
            {{task.title}}
            {{#if task.completed}}
              <span class="done">✓</span>
            {{/if}}
          </li>
        {{/each}}
      </ul>
    {{else}}
      <p class="empty-state">No tasks yet</p>
    {{/if}}
  </template>
}

For complex conditions, use getters:

Use {{#if}} to guard {{#each}} and avoid rendering empty states:

Bad:

{{#if @user}}
  {{#if @user.isPremium}}
    {{#if @user.hasAccess}}
      <PremiumContent />
    {{/if}}
  {{/if}}
{{/if}}

Good:

// app/components/data-display.gjs
import Component from '@glimmer/component';
import { Resource } from 'ember-resources';
import { resource } from 'ember-resources';

class DataResource extends Resource {
  @tracked data = null;
  @tracked isLoading = true;
  @tracked error = null;

  modify(positional, named) {
    this.fetchData(named.url);
  }

  async fetchData(url) {
    this.isLoading = true;
    this.error = null;
    try {
      const response = await fetch(url);
      this.data = await response.json();
    } catch (e) {
      this.error = e.message;
    } finally {
      this.isLoading = false;
    }
  }
}

class DataDisplay extends Component {
  @resource data = DataResource.from(() => ({
    url: this.args.url,
  }));

  <template>
    {{#if this.data.isLoading}}
      <div class="loading">Loading...</div>
    {{else if this.data.error}}
      <div class="error">Error: {{this.data.error}}</div>
    {{else}}
      <div class="content">
        {{this.data.data}}
      </div>
    {{/if}}
  </template>
}

Use conditional rendering for component selection:

Pattern for async data with loading/error states:

  • Chained if/else: 40-60% faster than multiple independent {{#if}} blocks

  • Extracted getters: ~20% faster for complex conditions (cached)

  • Component switching: Same performance as {{#if}} but better code organization

  • {{#if}}/{{#else}}: For simple true/false conditions

  • Extracted getters: For complex or reused conditions

  • Component switching: For different component types based on state

  • Guard clauses: To avoid rendering large subtrees when not needed

  • Ember Guides - Conditionals

  • Glimmer VM Performance

  • @cached decorator

6.6 Template-Only Components with In-Scope Functions

Impact: MEDIUM (Clean, performant patterns for template-only components)

For template-only components (components without a class and this), use in-scope functions to keep logic close to the template while avoiding unnecessary caching overhead.

Incorrect: using class-based component for simple logic

// app/components/product-card.gjs
import Component from '@glimmer/component';

export class ProductCard extends Component {
  // Unnecessary class and overhead for simple formatting
  formatPrice(price) {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
    }).format(price);
  }

  <template>
    <div class="product-card">
      <h3>{{@product.name}}</h3>
      <div class="price">{{this.formatPrice @product.price}}</div>
    </div>
  </template>
}

Correct: template-only component with in-scope functions

// app/components/product-card.gjs
function formatPrice(price) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(price);
}

function calculateDiscount(price, discountPercent) {
  return price * (1 - discountPercent / 100);
}

function isOnSale(product) {
  return product.discountPercent > 0;
}

<template>
  <div class="product-card">
    <h3>{{@product.name}}</h3>

    {{#if (isOnSale @product)}}
      <div class="price">
        <span class="original">{{formatPrice @product.price}}</span>
        <span class="sale">
          {{formatPrice (calculateDiscount @product.price @product.discountPercent)}}
        </span>
      </div>
    {{else}}
      <div class="price">{{formatPrice @product.price}}</div>
    {{/if}}

    <p>{{@product.description}}</p>
  </div>
</template>

When to use class-based vs template-only:

// Use template-only when:
// - Simple transformations
// - Functions accessed once
// - No state or services needed

function formatDate(date) {
  return new Date(date).toLocaleDateString();
}

<template>
  <div class="timestamp">
    Last updated:
    {{formatDate @lastUpdate}}
  </div>
</template>

Combining in-scope functions for readability:

// app/components/user-badge.gjs
function getInitials(name) {
  return name
    .split(' ')
    .map((part) => part[0])
    .join('')
    .toUpperCase();
}

function getBadgeColor(status) {
  const colors = {
    active: 'green',
    pending: 'yellow',
    inactive: 'gray',
  };
  return colors[status] || 'gray';
}

<template>
  <div class="user-badge" style="background-color: {{getBadgeColor @user.status}}">
    <span class="initials">{{getInitials @user.name}}</span>
    <span class="name">{{@user.name}}</span>
  </div>
</template>

Anti-pattern - Complex nested calls:

// ❌ Hard to read, lots of nesting
<template>
  <div>
    {{formatCurrency (multiply (add @basePrice @taxAmount) @quantity)}}
  </div>
</template>

// ✅ Better - use intermediate function
function calculateTotal(basePrice, taxAmount, quantity) {
  return (basePrice + taxAmount) * quantity;
}

function formatCurrency(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}

<template>
  <div>
    {{formatCurrency (calculateTotal @basePrice @taxAmount @quantity)}}
  </div>
</template>

Key differences from class-based components:

| Aspect | Template-Only | Class-Based |

| ---------------- | ------------------------ | ------------------------ |

| this context | No this | Has this |

| Function caching | Recreated each render | @cached available |

| Services | Cannot inject | @service decorator |

| Tracked state | No instance state | @tracked properties |

| Best for | Simple, stateless | Complex, stateful |

Best practices:

// app/components/stats-display.gjs
export function average(numbers) {
  if (numbers.length === 0) return 0;
  return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}

export function round(number, decimals = 2) {
  return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
}

<template>
  <div class="stats">
    Average:
    {{round (average @scores)}}
  </div>
</template>
  1. Keep functions simple - If computation is complex, consider a class with @cached

  2. One responsibility per function - Makes them reusable and testable

  3. Minimize nesting - Use intermediate functions for readability

  4. No side effects - Functions should be pure transformations

  5. Export for testing - Export functions so they can be tested independently

Reference: https://guides.emberjs.com/release/components/component-types/, https://guides.emberjs.com/release/components/conditional-content/

6.7 Use {{#each}} with @key for Lists

Impact: MEDIUM (50-100% faster list updates)

Use the key= parameter with {{#each}} when objects are recreated between renders (e.g., via .map() or fresh API data). The default behavior uses object identity (@identity), which works when object references are stable.

Incorrect: no key

// app/components/user-list.gjs
import UserCard from './user-card';

<template>
  <ul>
    {{#each this.users as |user|}}
      <li>
        <UserCard @user={{user}} />
      </li>
    {{/each}}
  </ul>
</template>

Correct: with key

// app/components/user-list.gjs
import UserCard from './user-card';

<template>
  <ul>
    {{#each this.users key="id" as |user|}}
      <li>
        <UserCard @user={{user}} />
      </li>
    {{/each}}
  </ul>
</template>

For arrays of primitives: strings, numbers

// app/components/tag-list.gjs
<template>
  {{! @identity is implicit, no need to write it }}
  {{#each this.tags as |tag|}}
    <span class="tag">{{tag}}</span>
  {{/each}}
</template>

@identity is the default, so you rarely need to specify it explicitly. It compares items by value for primitives.

For complex scenarios with @index:

// app/components/item-list.gjs
<template>
  {{#each this.items key="@index" as |item index|}}
    <div data-index={{index}}>
      {{item.name}}
    </div>
  {{/each}}
</template>

Using proper keys allows Ember's rendering engine to efficiently update, reorder, and remove items without re-rendering the entire list.

When to use key=:

  • Objects recreated between renders (.map(), generators, fresh API responses) → use key="id" or similar

  • High-frequency updates (animations, real-time data) → always specify a key

  • Stable object references (Apollo cache, Ember Data) → default @identity is fine

  • Items never reorder → key="@index" is acceptable

Performance comparison: dbmon benchmark, 40 rows at 60fps

6.8 Use {{#let}} to Avoid Recomputation

Impact: MEDIUM (30-50% reduction in duplicate work)

Use {{#let}} to compute expensive values once and reuse them in the template instead of calling getters or helpers multiple times.

Incorrect: recomputes on every reference

// app/components/user-card.gjs
<template>
  <div class="user-card">
    {{#if (and this.user.isActive (not this.user.isDeleted))}}
      <h3>{{this.user.fullName}}</h3>
      <p>Status: Active</p>
    {{/if}}

    {{#if (and this.user.isActive (not this.user.isDeleted))}}
      <button {{on "click" this.editUser}}>Edit</button>
    {{/if}}

    {{#if (and this.user.isActive (not this.user.isDeleted))}}
      <button {{on "click" this.deleteUser}}>Delete</button>
    {{/if}}
  </div>
</template>

Correct: compute once, reuse

// app/components/user-card.gjs
<template>
  {{#let (and this.user.isActive (not this.user.isDeleted)) as |isEditable|}}
    <div class="user-card">
      {{#if isEditable}}
        <h3>{{this.user.fullName}}</h3>
        <p>Status: Active</p>
      {{/if}}

      {{#if isEditable}}
        <button {{on "click" this.editUser}}>Edit</button>
      {{/if}}

      {{#if isEditable}}
        <button {{on "click" this.deleteUser}}>Delete</button>
      {{/if}}
    </div>
  {{/let}}
</template>

Multiple values:

// app/components/checkout.gjs
<template>
  {{#let
    (this.calculateTotal this.items) (this.formatCurrency this.total) (this.hasDiscount this.user)
    as |total formattedTotal showDiscount|
  }}
    <div class="checkout">
      <p>Total: {{formattedTotal}}</p>

      {{#if showDiscount}}
        <p>Original: {{total}}</p>
        <p>Discount Applied!</p>
      {{/if}}
    </div>
  {{/let}}
</template>

{{#let}} computes values once and caches them for the block scope, reducing redundant calculations.

6.9 Use {{fn}} for Partial Application Only

Impact: LOW-MEDIUM (Clearer code, avoid unnecessary wrapping)

The {{fn}} helper is used for partial application (binding arguments), similar to JavaScript's .bind(). Only use it when you need to pre-bind arguments to a function. Don't use it to simply pass a function reference.

Incorrect: unnecessary use of {{fn}}

// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

class Search extends Component {
  @action
  handleSearch(event) {
    console.log('Searching:', event.target.value);
  }

  <template>
    {{! Wrong - no arguments being bound}}
    <input {{on "input" (fn this.handleSearch)}} />
  </template>
}

Correct: direct function reference

// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

class Search extends Component {
  @action
  handleSearch(event) {
    console.log('Searching:', event.target.value);
  }

  <template>
    {{! Correct - pass function directly}}
    <input {{on "input" this.handleSearch}} />
  </template>
}

When to Use {{fn}} - Partial Application:

// app/components/user-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

class UserList extends Component {
  @action
  deleteUser(userId, event) {
    console.log('Deleting user:', userId);
    this.args.onDelete(userId);
  }

  <template>
    <ul>
      {{#each @users as |user|}}
        <li>
          {{user.name}}
          {{! Correct - binding user.id as first argument}}
          <button {{on "click" (fn this.deleteUser user.id)}}>
            Delete
          </button>
        </li>
      {{/each}}
    </ul>
  </template>
}

Use {{fn}} when you need to pre-bind arguments to a function, similar to JavaScript's .bind():

Multiple Arguments:

// app/components/data-grid.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

class DataGrid extends Component {
  @action
  updateCell(rowId, columnKey, event) {
    const newValue = event.target.value;
    this.args.onUpdate(rowId, columnKey, newValue);
  }

  <template>
    {{#each @rows as |row|}}
      {{#each @columns as |column|}}
        <input
          value={{get row column.key}}
          {{! Pre-binding rowId and columnKey}}
          {{on "input" (fn this.updateCell row.id column.key)}}
        />
      {{/each}}
    {{/each}}
  </template>
}

Think of {{fn}} like .bind():

// JavaScript comparison
const boundFn = this.deleteUser.bind(this, userId); // .bind() pre-binds args
// Template equivalent: {{fn this.deleteUser userId}}

// Direct reference
const directFn = this.handleSearch; // No pre-binding
// Template equivalent: {{this.handleSearch}}

Common Patterns:

// ❌ Wrong - no partial application
<button {{on "click" (fn this.save)}}>Save</button>

// ✅ Correct - direct reference
<button {{on "click" this.save}}>Save</button>

// ✅ Correct - partial application with argument
<button {{on "click" (fn this.save "draft")}}>Save Draft</button>

// ❌ Wrong - no partial application
<input {{on "input" (fn this.handleInput)}} />

// ✅ Correct - direct reference
<input {{on "input" this.handleInput}} />

// ✅ Correct - partial application with field name
<input {{on "input" (fn this.updateField "email")}} />

Only use {{fn}} when you're binding arguments. For simple function references, pass them directly.

Reference: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_passing-arguments-to-functions

6.10 Use Helper Libraries Effectively

Impact: MEDIUM (Reduces custom helper maintenance and keeps templates concise)

Leverage community helper libraries to write cleaner templates and avoid creating unnecessary custom helpers for common operations.

Reinventing common functionality with custom helpers adds maintenance burden and bundle size when well-maintained helper libraries already provide the needed functionality.

Incorrect:

// app/utils/is-equal.js - Unnecessary custom helper
export function isEqual(a, b) {
  return a === b;
}

// app/components/user-badge.gjs
import { isEqual } from '../utils/is-equal';

class UserBadge extends Component {
  <template>
    {{#if (isEqual @user.role "admin")}}
      <span class="badge">Admin</span>
    {{/if}}
  </template>
}

Note: These helpers will be built into Ember 7 core, but currently require installing the respective addon packages.

Installation:

npm install ember-truth-helpers ember-composable-helpers

Use helper libraries like ember-truth-helpers and ember-composable-helpers:

Correct:

// app/components/conditional-inline.gjs
import Component from '@glimmer/component';
import { if as ifHelper } from '@ember/helper'; // Built-in to Ember

class ConditionalInline extends Component {
  <template>
    {{! Ternary-like behavior }}
    <span class={{ifHelper @isActive "active" "inactive"}}>
      {{@user.name}}
    </span>

    {{! Conditional attribute }}
    <button disabled={{ifHelper @isProcessing true}}>
      {{ifHelper @isProcessing "Processing..." "Submit"}}
    </button>

    {{! With default value }}
    <p>{{ifHelper @description @description "No description provided"}}</p>
  </template>
}

Installation: npm install ember-truth-helpers

Installation: npm install ember-composable-helpers

Dynamic Classes:

// app/components/dynamic-classes.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper } from '@ember/helper'; // Built-in to Ember
import { and, not } from 'ember-truth-helpers';

class DynamicClasses extends Component {
  <template>
    <div
      class={{concat
        "card "
        (ifHelper @isPremium "premium ")
        (ifHelper (and @isNew (not @isRead)) "unread ")
        @customClass
      }}
    >
      <h3>{{@title}}</h3>
    </div>
  </template>
}

List Filtering:

// app/components/user-profile-card.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper, fn } from '@ember/helper'; // Built-in to Ember
import { eq, not, and, or } from 'ember-truth-helpers';
import { hash, array, get } from 'ember-composable-helpers/helpers';
import { on } from '@ember/modifier';

class UserProfileCard extends Component {
  updateField = (field, value) => {
    this.args.onUpdate(field, value);
  };

  <template>
    <div
      class={{concat
        "profile-card "
        (ifHelper @user.isPremium "premium ")
        (ifHelper (and @user.isOnline (not @user.isAway)) "online ")
      }}
    >
      <h2>{{concat @user.firstName " " @user.lastName}}</h2>

      {{#if (or (eq @user.role "admin") (eq @user.role "moderator"))}}
        <span class="badge">
          {{get (hash admin="Administrator" moderator="Moderator") @user.role}}
        </span>
      {{/if}}

      {{#if (and @canEdit (not @user.locked))}}
        <div class="actions">
          {{#each (array "profile" "settings" "privacy") as |section|}}
            <button {{on "click" (fn this.updateField "activeSection" section)}}>
              Edit
              {{section}}
            </button>
          {{/each}}
        </div>
      {{/if}}

      <p class={{ifHelper @user.verified "verified" "unverified"}}>
        {{ifHelper @user.bio @user.bio "No bio provided"}}
      </p>
    </div>
  </template>
}
  • Library helpers: ~0% overhead (compiled into efficient bytecode)

  • Custom helpers: 5-15% overhead per helper call

  • Inline logic: Cleaner templates, better tree-shaking

  • Library helpers: For all common operations (equality, logic, arrays, strings)

  • Custom helpers: Only for domain-specific logic not covered by library helpers

  • Component logic: For complex operations that need @cached or multiple dependencies

Note: These helpers will be built into Ember 7 core. Until then:

Actually Built-in to Ember (from @ember/helper):

  • concat - Concatenate strings

  • fn - Partial application / bind arguments

  • if - Ternary-like conditional value

  • mut - Create settable binding (use sparingly)

From ember-truth-helpers package:

  • eq - Equality (===)

  • not - Negation (!)

  • and - Logical AND

  • or - Logical OR

  • lt, lte, gt, gte - Numeric comparisons

From ember-composable-helpers package:


7. Performance Optimization

Impact: MEDIUM

Performance-focused rendering and event handling patterns help reduce unnecessary work in hot UI paths.

7.1 Use {{on}} Modifier Instead of Event Handler Properties

Impact: MEDIUM (Better performance and clearer event handling)

Always use the {{on}} modifier for event handling instead of HTML event handler properties. The {{on}} modifier provides better memory management, automatic cleanup, and clearer intent.

Why {{on}} is Better:

  • Automatic cleanup when element is removed (prevents memory leaks)

  • Supports event options (capture, passive, once)

  • More explicit and searchable in templates

Incorrect: HTML event properties

// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class Button extends Component {
  @action
  handleClick() {
    console.log('clicked');
  }

  <template>
    <button onclick={{this.handleClick}}>
      Click Me
    </button>
  </template>
}

Correct: {{on}} modifier

// app/components/scrollable.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class Scrollable extends Component {
  @action
  handleScroll(event) {
    console.log('scrolled', event.target.scrollTop);
  }

  <template>
    {{! passive: true improves scroll performance }}
    <div {{on "scroll" this.handleScroll passive=true}}>
      {{yield}}
    </div>
  </template>
}

The {{on}} modifier supports standard event listener options:

Available options:

// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class TodoList extends Component {
  @action
  handleClick(event) {
    // Find which todo was clicked
    const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
    if (todoId) {
      this.args.onTodoClick?.(todoId);
    }
  }

  <template>
    {{! Single listener for all todos - better than one per item }}
    <ul {{on "click" this.handleClick}}>
      {{#each @todos as |todo|}}
        <li data-todo-id={{todo.id}}>
          {{todo.title}}
        </li>
      {{/each}}
    </ul>
  </template>
}
  • capture - Use capture phase instead of bubble phase

  • once - Remove listener after first invocation

  • passive - Indicates handler won't call preventDefault() (better scroll performance)

Handle these in your action, not in the template:

For lists with many items, use event delegation on the parent:

Don't bind directly without @action:

// This won't work - loses 'this' context
<button {{on "click" this.myMethod}}>Bad</button>

Use @action decorator:

@action
myMethod() {
  // 'this' is correctly bound
}

<button {{on "click" this.myMethod}}>Good</button>

Don't use string event handlers:

{{! Security risk and doesn't work in strict mode }}
<button onclick="handleClick()">Bad</button>

Always use the {{on}} modifier for cleaner, safer, and more performant event handling in Ember applications.

References:


8. Testing Best Practices

Impact: MEDIUM

Modern testing patterns, waiters, and abstraction utilities improve test reliability and maintainability.

8.1 MSW (Mock Service Worker) Setup for Testing

Impact: HIGH (Proper API mocking without ORM complexity)

Use MSW (Mock Service Worker) for API mocking in tests. MSW provides a cleaner approach than Mirage by intercepting requests at the network level without introducing unnecessary ORM patterns or abstractions.

Create a test helper to set up MSW in your tests:

MSW works in integration tests too:

  1. Define handlers per test - Use server.use() in individual tests rather than global handlers

  2. Reset between tests - The helper automatically resets handlers after each test

  3. Use JSON:API format - Keep responses consistent with your API format

  4. Test error states - Mock various HTTP error codes (400, 401, 403, 404, 500)

  5. Capture requests - Use the request object to verify what your app sent

  6. Use fixtures - Create reusable test data to keep tests DRY

  7. Simulate delays - Test loading states with artificial delays

  8. Type-safe responses - In TypeScript, type your response payloads

Incorrect: using Mirage with ORM complexity

// mirage/config.js
export default function () {
  this.namespace = '/api';

  // Complex schema and factories
  this.get('/users', (schema) => {
    return schema.users.all();
  });

  // Need to maintain schema, factories, serializers
  this.post('/users', (schema, request) => {
    let attrs = JSON.parse(request.requestBody);
    return schema.users.create(attrs);
  });
}

Correct: using MSW with simple network mocking

// tests/helpers/msw.js
import { http, HttpResponse } from 'msw';

// Simple request/response mocking
export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const user = await request.json();
    return HttpResponse.json({ id: 3, ...user }, { status: 201 });
  }),
];

Why MSW over Mirage:

  • Better conventions - Mock at the network level, not with an ORM

  • Simpler mental model - Define request handlers, return responses

  • Doesn't lead developers astray - No schema migrations or factories to maintain

  • Works everywhere - Same mocks work in tests, Storybook, and development

  • More realistic - Actually intercepts fetch/XMLHttpRequest

Default handlers for all tests:

// tests/helpers/msw.js
const defaultHandlers = [
  // Always return current user
  http.get('/api/current-user', () => {
    return HttpResponse.json({
      data: {
        id: '1',
        type: 'user',
        attributes: { name: 'Test User', role: 'admin' },
      },
    });
  }),
];

One-time handlers (don't persist):

// MSW handlers persist until resetHandlers() is called
// The test helper automatically resets after each test
// For a one-time handler within a test, manually reset:
test('one-time response', async function (assert) {
  server.use(
    http.get('/api/special', () => {
      return HttpResponse.json({ data: 'special' });
    }),
  );

  // First request gets mocked response
  await visit('/special');
  assert.dom('[data-test-data]').hasText('special');

  // Reset to remove this handler
  server.resetHandlers();

  // Subsequent requests will use default handlers or be unhandled
});

Conditional responses:

http.post('/api/login', async ({ request }) => {
  const { email, password } = await request.json();

  if (email === 'test@example.com' && password === 'password') {
    return HttpResponse.json({
      data: { token: 'abc123' },
    });
  }

  return HttpResponse.json({ errors: [{ title: 'Invalid credentials' }] }, { status: 401 });
});

If migrating from Mirage:

  1. Remove ember-cli-mirage dependency

  2. Delete mirage/ directory (models, factories, scenarios)

  3. Install MSW: npm install --save-dev msw

  4. Create the MSW test helper (see above)

  5. Replace setupMirage(hooks) with setupMSW(hooks)

  6. Convert Mirage handlers:

    • this.server.get()http.get()

    • this.server.create() → Return inline JSON

    • this.server.createList() → Return array of JSON objects

Before: Mirage

test('lists posts', async function (assert) {
  this.server.createList('post', 3);
  await visit('/posts');
  assert.dom('[data-test-post]').exists({ count: 3 });
});

After: MSW

test('lists posts', async function (assert) {
  server.use(
    http.get('/api/posts', () => {
      return HttpResponse.json({
        data: [
          { id: '1', type: 'post', attributes: { title: 'Post 1' } },
          { id: '2', type: 'post', attributes: { title: 'Post 2' } },
          { id: '3', type: 'post', attributes: { title: 'Post 3' } },
        ],
      });
    }),
  );
  await visit('/posts');
  assert.dom('[data-test-post]').exists({ count: 3 });
});

Reference: https://discuss.emberjs.com/t/my-cookbook-for-various-emberjs-things/19679, https://mswjs.io/docs/

8.2 Provide DOM-Abstracted Test Utilities for Library Components

Impact: MEDIUM (Stabilizes consumer tests against internal DOM refactors)

When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered private unless the author of the tests is the owner of the code being tested.

Without abstracted test utilities:

  • Component refactoring breaks consumer tests

  • Tests are tightly coupled to implementation details

  • Teams waste time updating tests when internals change

  • Testing becomes fragile and maintenance-heavy

Library authors should provide test utilities that fully abstract the DOM. These utilities expose a public API for testing that remains stable even when internal implementation changes.

Incorrect: exposing DOM to consumers

// Consumer's test - tightly coupled to DOM
import { render, click } from '@ember/test-helpers';
import { DataGrid } from 'my-library';

test('sorting works', async function (assert) {
  await render(<template><DataGrid @rows={{this.rows}} /></template>);

  // Fragile: breaks if class names or structure change
  await click('.data-grid__header .sort-button[data-column="name"]');

  assert.dom('.data-grid__row:first-child').hasText('Alice');
});

Problems:

  • Consumer knows about .data-grid__header, .sort-button, [data-column]

  • Refactoring component structure breaks consumer tests

  • No clear public API for testing

Correct: providing DOM-abstracted test utilities

// Consumer's test - abstracted from DOM
import { render } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
import { getDataGrid } from 'my-library/test-support';

test('sorting works', async function (assert) {
  await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);

  const grid = getDataGrid();

  // Clean API: no DOM knowledge required
  await grid.sortBy('name');

  assert.strictEqual(grid.getRow(0), 'Alice');
  assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
});

Benefits:

// addon/test-support/modal.js
import { click, find, waitUntil } from '@ember/test-helpers';

export class ModalTestHelper {
  constructor(container = document) {
    this.container = container;
  }

  get element() {
    return find('[data-test-modal]', this.container);
  }

  isOpen() {
    return this.element !== null;
  }

  async waitForOpen() {
    await waitUntil(() => this.isOpen(), { timeout: 1000 });
  }

  async waitForClose() {
    await waitUntil(() => !this.isOpen(), { timeout: 1000 });
  }

  getTitle() {
    const titleEl = find('[data-test-modal-title]', this.element);
    return titleEl ? titleEl.textContent.trim() : null;
  }

  getBody() {
    const bodyEl = find('[data-test-modal-body]', this.element);
    return bodyEl ? bodyEl.textContent.trim() : null;
  }

  async close() {
    if (!this.isOpen()) {
      throw new Error('Cannot close modal: modal is not open');
    }
    await click('[data-test-modal-close]', this.element);
  }

  async clickButton(buttonText) {
    const buttons = findAll('[data-test-modal-button]', this.element);
    const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
    if (!button) {
      const available = buttons.map((b) => b.textContent.trim()).join(', ');
      throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
    }
    await click(button);
  }
}

export function getModal(container) {
  return new ModalTestHelper(container);
}
  • Component internals can change without breaking consumer tests

  • Clear, documented testing API

  • Consumer tests are declarative and readable

  • Library maintains API stability contract

On projects with teams, DOM abstraction prevents:

  • Merge conflicts from test changes

  • Cross-team coordination overhead

  • Broken tests from uncoordinated refactoring

  • Knowledge silos about component internals

For solo projects, the benefit is smaller but still valuable:

  • Easier refactoring without test maintenance

  • Better separation of concerns

  • Professional API design practice

Before: ~30-50% of test maintenance time spent updating selectors

After: Minimal test maintenance when refactoring components

8.3 Use Appropriate Render Patterns in Tests

Impact: MEDIUM (Simpler test code and better readability)

Choose the right rendering pattern based on whether your component needs arguments, blocks, or attributes in the test.

Incorrect: using template tag unnecessarily

// tests/integration/components/loading-spinner-test.js
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';

test('it renders', async function (assert) {
  // ❌ Unnecessary template wrapper for component with no args
  await render(
    <template>
      <LoadingSpinner />
    </template>,
  );

  assert.dom('[data-test-spinner]').exists();
});

Correct: direct component render when no args needed

// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';

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

  test('it renders with arguments', async function (assert) {
    const user = { name: 'John Doe', email: 'john@example.com' };

    // ✅ Use template tag when passing arguments
    await render(<template><UserCard @user={{user}} /></template>);

    assert.dom('[data-test-user-name]').hasText('John Doe');
  });

  test('it renders with block content', async function (assert) {
    // ✅ Use template tag when providing blocks
    await render(
      <template>
        <UserCard>
          <:header>Custom Header</:header>
          <:body>Custom Content</:body>
        </UserCard>
      </template>,
    );

    assert.dom('[data-test-header]').hasText('Custom Header');
    assert.dom('[data-test-body]').hasText('Custom Content');
  });

  test('it renders with HTML attributes', async function (assert) {
    // ✅ Use template tag when passing HTML attributes
    await render(<template><UserCard class="featured" data-test-featured /></template>);

    assert.dom('[data-test-featured]').exists();
    assert.dom('[data-test-featured]').hasClass('featured');
  });
});

Pattern 1: Direct component render (no args/blocks/attributes):

Pattern 2: Template tag render (with args/blocks/attributes):

Complete example showing both patterns:

// tests/integration/components/button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import Button from 'my-app/components/button';

module('Integration | Component | button', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders default button', async function (assert) {
    // ✅ No args needed - use direct render
    await render(Button);

    assert.dom('button').exists();
    assert.dom('button').hasText('Click me');
  });

  test('it renders with custom text', async function (assert) {
    // ✅ Needs block content - use template tag
    await render(
      <template>
        <Button>Submit Form</Button>
      </template>,
    );

    assert.dom('button').hasText('Submit Form');
  });

  test('it handles click action', async function (assert) {
    assert.expect(1);

    const handleClick = () => {
      assert.ok(true, 'Click handler called');
    };

    // ✅ Needs argument - use template tag
    await render(
      <template>
        <Button @onClick={{handleClick}}>Click me</Button>
      </template>,
    );

    await click('button');
  });

  test('it applies variant styling', async function (assert) {
    // ✅ Needs argument - use template tag
    await render(
      <template>
        <Button @variant="primary">Primary Button</Button>
      </template>,
    );

    assert.dom('button').hasClass('btn-primary');
  });
});

Testing template-only components:

// tests/integration/components/icon-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import Icon from 'my-app/components/icon';

module('Integration | Component | icon', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders default icon', async function (assert) {
    // ✅ Template-only component with no args - use direct render
    await render(Icon);

    assert.dom('[data-test-icon]').exists();
  });

  test('it renders specific icon', async function (assert) {
    // ✅ Needs @name argument - use template tag
    await render(<template><Icon @name="check" @size="large" /></template>);

    assert.dom('[data-test-icon]').hasAttribute('data-icon', 'check');
    assert.dom('[data-test-icon]').hasClass('icon-large');
  });
});

Decision guide:

| Scenario | Pattern | Example |

| ----------------------------------- | ---------------------------------- | ----------------------------------------------------------- |

| No arguments, blocks, or attributes | render(Component) | render(LoadingSpinner) |

| Component needs arguments | render(<template>...</template>) | render(<template><Card @title="Hello" /></template>) |

| Component receives block content | render(<template>...</template>) | render(<template><Card>Content</Card></template>) |

| Component needs HTML attributes | render(<template>...</template>) | render(<template><Card class="featured" /></template>) |

| Multiple test context properties | render(<template>...</template>) | render(<template><Card @data={{this.data}} /></template>) |

Why this matters:

  • Simplicity: Direct render reduces boilerplate for simple cases

  • Clarity: Template syntax makes data flow explicit when needed

  • Consistency: Clear pattern helps teams write maintainable tests

  • Type Safety: Both patterns work with TypeScript for component types

Common patterns:

// ✅ Simple component, no setup needed
await render(LoadingSpinner);
await render(Divider);
await render(Logo);

// ✅ Component with arguments from test context
await render(
  <template><UserList @users={{this.users}} @onSelect={{this.handleSelect}} /></template>,
);

// ✅ Component with named blocks
await render(
  <template>
    <Modal>
      <:header>Title</:header>
      <:body>Content</:body>
      <:footer><button>Close</button></:footer>
    </Modal>
  </template>,
);

// ✅ Component with splattributes
await render(
  <template>
    <Card class="highlighted" data-test-card role="article">
      Card content
    </Card>
  </template>,
);

Using the appropriate render pattern keeps tests clean and expressive.

Reference: https://guides.emberjs.com/release/testing/

8.4 Use Modern Testing Patterns

Impact: HIGH (Better test coverage and maintainability)

Use modern Ember testing patterns with @ember/test-helpers and qunit-dom for better test coverage and maintainability.

Incorrect: old testing patterns

// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';

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

  test('it renders', async function (assert) {
    await render(<template><UserCard /></template>);

    // Using find() instead of qunit-dom
    assert.ok(find('.user-card'));
  });
});

Correct: modern testing patterns

// 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 { setupIntl } from 'ember-intl/test-support';
import UserCard from 'my-app/components/user-card';

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

  test('it renders user information', async function (assert) {
    const user = {
      name: 'John Doe',
      email: 'john@example.com',
      avatarUrl: '/avatar.jpg',
    };

    await render(<template><UserCard @user={{user}} /></template>);

    // qunit-dom assertions
    assert.dom('[data-test-user-name]').hasText('John Doe');
    assert.dom('[data-test-user-email]').hasText('john@example.com');
    assert
      .dom('[data-test-user-avatar]')
      .hasAttribute('src', '/avatar.jpg')
      .hasAttribute('alt', 'John Doe');
  });

  test('it handles edit action', async function (assert) {
    assert.expect(1);

    const user = { name: 'John Doe', email: 'john@example.com' };
    const handleEdit = (editedUser) => {
      assert.deepEqual(editedUser, user, 'Edit handler called with user');
    };

    await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);

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

Component testing with reactive state:

// tests/integration/components/search-box-test.ts
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { trackedObject } from '@ember/reactive/collections';
import SearchBox from 'my-app/components/search-box';

module('Integration | Component | search-box', function (hooks) {
  setupRenderingTest(hooks);

  test('it performs search', async function (assert) {
    // Use trackedObject for reactive state in tests
    const state = trackedObject({
      results: [] as string[],
    });

    const handleSearch = (query: string) => {
      state.results = [`Result for ${query}`];
    };

    await render(
      <template>
        <SearchBox @onSearch={{handleSearch}} />
        <ul data-test-results>
          {{#each state.results as |result|}}
            <li>{{result}}</li>
          {{/each}}
        </ul>
      </template>,
    );

    await fillIn('[data-test-search-input]', 'ember');

    // State updates reactively - no waitFor needed when using test-waiters
    assert.dom('[data-test-results] li').hasText('Result for ember');
  });
});

Testing with ember-concurrency tasks:

// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import AsyncButton from 'my-app/components/async-button';

module('Integration | Component | async-button', function (hooks) {
  setupRenderingTest(hooks);

  test('it shows loading state during task execution', async function (assert) {
    let resolveTask;
    const onSave = () => {
      return new Promise((resolve) => {
        resolveTask = resolve;
      });
    };

    await render(
      <template>
        <AsyncButton @onSave={{onSave}}>
          Save
        </AsyncButton>
      </template>,
    );

    // Trigger the task
    await click('[data-test-button]');

    // ember-concurrency automatically registers test waiters
    // The button will be disabled while the task runs
    assert.dom('[data-test-button]').hasAttribute('disabled');
    assert.dom('[data-test-loading-spinner]').hasText('Saving...');

    // Resolve the task
    resolveTask();
    // No need to call settled() - ember-concurrency's test waiters handle this

    assert.dom('[data-test-button]').doesNotHaveAttribute('disabled');
    assert.dom('[data-test-loading-spinner]').doesNotExist();
    assert.dom('[data-test-button]').hasText('Save');
  });
});

When to use test-waiters with ember-concurrency:

  • ember-concurrency auto-registers test waiters - You don't need to manually register test waiters for ember-concurrency tasks. The library automatically waits for tasks to complete before test helpers like click(), fillIn(), etc. resolve.

  • You still need test-waiters when:

    • Using raw Promises outside of ember-concurrency tasks

    • Working with third-party async operations that don't integrate with Ember's test waiter system

    • Creating custom async behavior that needs to pause test execution

  • You DON'T need additional test-waiters when:

    • Using ember-concurrency tasks (already handled)

    • Using Ember Data operations (already handled)

    • Using settled() from @ember/test-helpers (already coordinates with test waiters)

    • Note: waitFor() and waitUntil() from @ember/test-helpers are code smells - if you need them, it indicates missing test-waiters in your code. Instrument your async operations with test-waiters instead.

Route testing with MSW: Mock Service Worker

// tests/acceptance/posts-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { http, HttpResponse } from 'msw';
import { setupMSW } from 'my-app/tests/helpers/msw';

module('Acceptance | posts', function (hooks) {
  setupApplicationTest(hooks);
  const { server } = setupMSW(hooks);

  test('visiting /posts', async function (assert) {
    server.use(
      http.get('/api/posts', () => {
        return HttpResponse.json({
          data: [
            { id: '1', type: 'post', attributes: { title: 'Post 1' } },
            { id: '2', type: 'post', attributes: { title: 'Post 2' } },
            { id: '3', type: 'post', attributes: { title: 'Post 3' } },
          ],
        });
      }),
    );

    await visit('/posts');

    assert.strictEqual(currentURL(), '/posts');
    assert.dom('[data-test-post-item]').exists({ count: 3 });
  });

  test('clicking a post navigates to detail', async function (assert) {
    server.use(
      http.get('/api/posts', () => {
        return HttpResponse.json({
          data: [{ id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } }],
        });
      }),
      http.get('/api/posts/test-post', () => {
        return HttpResponse.json({
          data: { id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } },
        });
      }),
    );

    await visit('/posts');
    await click('[data-test-post-item]:first-child');

    assert.strictEqual(currentURL(), '/posts/test-post');
    assert.dom('[data-test-post-title]').hasText('Test Post');
  });
});

Note: Use MSW (Mock Service Worker) for API mocking instead of Mirage. MSW provides better conventions and doesn't lead developers astray. See testing-msw-setup.md for detailed setup instructions.

Accessibility testing:

// tests/integration/components/modal-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import Modal from 'my-app/components/modal';

module('Integration | Component | modal', function (hooks) {
  setupRenderingTest(hooks);

  test('it passes accessibility audit', async function (assert) {
    await render(
      <template>
        <Modal @isOpen={{true}} @title="Test Modal">
          <p>Modal content</p>
        </Modal>
      </template>,
    );

    await a11yAudit();
    assert.ok(true, 'no a11y violations');
  });

  test('it traps focus', async function (assert) {
    await render(
      <template>
        <Modal @isOpen={{true}}>
          <button data-test-first>First</button>
          <button data-test-last>Last</button>
        </Modal>
      </template>,
    );

    assert.dom('[data-test-first]').isFocused();

    // Tab should stay within modal
    await click('[data-test-last]');
    assert.dom('[data-test-last]').isFocused();
  });
});

Testing with data-test attributes:

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

class UserProfile extends Component {
  <template>
    <div class="user-profile" data-test-user-profile>
      <img src={{@user.avatar}} alt={{@user.name}} data-test-avatar />
      <h2 data-test-name>{{@user.name}}</h2>
      <p data-test-email>{{@user.email}}</p>

      {{#if @onEdit}}
        <button {{on "click" (fn @onEdit @user)}} data-test-edit-button>
          Edit
        </button>
      {{/if}}
    </div>
  </template>
}

Modern testing patterns with @ember/test-helpers, qunit-dom, and data-test attributes provide better test reliability, readability, and maintainability.

Reference: https://guides.emberjs.com/release/testing/

8.5 Use qunit-dom for Better Test Assertions

Impact: MEDIUM (More readable and maintainable tests)

Use qunit-dom for DOM assertions in tests. It provides expressive, chainable assertions that make tests more readable and provide better error messages than raw QUnit assertions.

Why qunit-dom:

  • More expressive and readable test assertions

  • Better error messages when tests fail

  • Type-safe with TypeScript

  • Reduces boilerplate in DOM testing

Incorrect: verbose QUnit assertions

// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';


module('Integration | Component | greeting', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function (assert) {
    await render(<template><Greeting @name="World" /></template>);

    const element = this.element.querySelector('.greeting');
    assert.ok(element, 'greeting element exists');
    assert.equal(element.textContent.trim(), 'Hello, World!', 'shows greeting');
    assert.ok(element.classList.contains('greeting'), 'has greeting class');
  });
});

Correct: expressive qunit-dom

// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';


module('Integration | Component | greeting', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function (assert) {
    await render(<template><Greeting @name="World" /></template>);

    assert.dom('.greeting').exists('greeting element exists');
    assert.dom('.greeting').hasText('Hello, World!', 'shows greeting');
  });
});

Existence and Visibility:

test('element visibility', async function (assert) {
  await render(
    <template>
      <MyComponent />
    </template>,
  );

  // Element exists in DOM
  assert.dom('[data-test-output]').exists();

  // Element doesn't exist
  assert.dom('[data-test-deleted]').doesNotExist();

  // Element is visible (not display: none or visibility: hidden)
  assert.dom('[data-test-visible]').isVisible();

  // Element is not visible
  assert.dom('[data-test-hidden]').isNotVisible();

  // Count elements
  assert.dom('[data-test-item]').exists({ count: 3 });
});

Text Content:

test('text assertions', async function (assert) {
  await render(<template><Article @title="Hello World" /></template>);

  // Exact text match
  assert.dom('h1').hasText('Hello World');

  // Contains text (partial match)
  assert.dom('p').containsText('Hello');

  // Any text exists
  assert.dom('h1').hasAnyText();

  // No text
  assert.dom('.empty').hasNoText();
});

Attributes:

test('attribute assertions', async function (assert) {
  await render(<template><Button @disabled={{true}} /></template>);

  // Has attribute (any value)
  assert.dom('button').hasAttribute('disabled');

  // Has specific attribute value
  assert.dom('button').hasAttribute('type', 'submit');

  // Attribute value matches regex
  assert.dom('a').hasAttribute('href', /^https:\/\//);

  // Doesn't have attribute
  assert.dom('button').doesNotHaveAttribute('aria-hidden');

  // Has ARIA attributes
  assert.dom('[role="button"]').hasAttribute('aria-label', 'Close dialog');
});

Classes:

test('class assertions', async function (assert) {
  await render(<template><Card @status="active" /></template>);

  // Has single class
  assert.dom('.card').hasClass('active');

  // Doesn't have class
  assert.dom('.card').doesNotHaveClass('disabled');

  // Has no classes at all
  assert.dom('.plain').hasNoClass();
});

Form Elements:

test('accessibility', async function (assert) {
  await render(<template><Modal @onClose={{this.close}} /></template>);

  // ARIA roles
  assert.dom('[role="dialog"]').exists();
  assert.dom('[role="dialog"]').hasAttribute('aria-modal', 'true');

  // Labels
  assert.dom('[aria-label="Close modal"]').exists();

  // Focus management
  assert.dom('[data-test-close-button]').isFocused();

  // Required fields
  assert.dom('input[name="email"]').hasAttribute('aria-required', 'true');
});

You can chain multiple assertions on the same element:

Add custom messages to make failures clearer:

Use qunit-dom for basic accessibility checks:

  1. Use data-test attributes for test selectors instead of classes:

    
    // Good
    
    assert.dom('[data-test-submit-button]').exists();
    
    // Avoid - classes can change
    
    assert.dom('.btn.btn-primary').exists();
    
    
  2. Make assertions specific:

    
    // Better - exact match
    
    assert.dom('h1').hasText('Welcome');
    
    // Less specific - could miss issues
    
    assert.dom('h1').containsText('Welc');
    
    
  3. Use meaningful custom messages:

    
    assert.dom('[data-test-error]').hasText('Invalid email', 'shows correct validation error');
    
    
  4. Combine with @ember/test-helpers:

    
    import { click, fillIn } from '@ember/test-helpers';
    
    await fillIn('[data-test-email]', 'user@example.com');
    
    await click('[data-test-submit]');
    
    assert.dom('[data-test-success]').exists();
    
    
  5. Test user-visible behavior, not implementation:

    
    // Good - tests what user sees
    
    assert.dom('[data-test-greeting]').hasText('Hello, Alice');
    
    // Avoid - tests implementation details
    
    assert.ok(this.component.internalState === 'ready');
    
    

qunit-dom makes your tests more maintainable and easier to understand. It comes pre-installed with ember-qunit, so you can start using it immediately.

References:

8.6 Use Test Waiters for Async Operations

Impact: HIGH (Reliable tests that don't depend on implementation details)

Instrument async code with test waiters instead of using waitFor() or waitUntil() in tests. Test waiters abstract async implementation details so tests focus on user behavior rather than timing.

Why Test Waiters Matter:

Test waiters allow settled() and other test helpers to automatically wait for your async operations. This means:

  • Tests don't need to know about implementation details (timeouts, polling intervals, etc.)

  • Tests are written from a user's perspective ("click button, see result")

  • Code refactoring doesn't break tests

  • Tests are more reliable and less flaky

Incorrect: testing implementation details

// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, waitFor } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';

module('Integration | Component | data-loader', function (hooks) {
  setupRenderingTest(hooks);

  test('it loads data', async function (assert) {
    await render(<template><DataLoader /></template>);

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

    // BAD: Test knows about implementation details
    // If the component changes from polling every 100ms to 200ms, test breaks
    await waitFor('[data-test-data]', { timeout: 5000 });

    assert.dom('[data-test-data]').hasText('Loaded data');
  });
});

Correct: using test waiters

// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, settled } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';

module('Integration | Component | data-loader', function (hooks) {
  setupRenderingTest(hooks);

  test('it loads data', async function (assert) {
    await render(<template><DataLoader /></template>);

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

    // GOOD: settled() automatically waits for test waiters
    // No knowledge of timing needed - tests from user's perspective
    await settled();

    assert.dom('[data-test-data]').hasText('Loaded data');
  });
});

Test waiter with cleanup:

// app/components/polling-widget.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
import { buildWaiter } from '@ember/test-waiters';

const waiter = buildWaiter('polling-widget');

export class PollingWidget extends Component {
  @tracked status = 'idle';
  intervalId = null;
  token = null;

  constructor(owner, args) {
    super(owner, args);

    registerDestructor(this, () => {
      this.stopPolling();
    });
  }

  startPolling = () => {
    // Register async operation
    this.token = waiter.beginAsync();

    this.intervalId = setInterval(() => {
      this.checkStatus();
    }, 1000);
  };

  stopPolling = () => {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }

    // End async operation on cleanup
    if (this.token) {
      waiter.endAsync(this.token);
      this.token = null;
    }
  };

  checkStatus = async () => {
    const response = await fetch('/api/status');
    this.status = await response.text();

    if (this.status === 'complete') {
      this.stopPolling();
    }
  };

  <template>
    <div>
      <button {{on "click" this.startPolling}} data-test-start>
        Start Polling
      </button>
      <div data-test-status>{{this.status}}</div>
    </div>
  </template>
}

Test waiter with Services:

// tests/unit/services/data-sync-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { settled } from '@ember/test-helpers';

module('Unit | Service | data-sync', function (hooks) {
  setupTest(hooks);

  test('it syncs data', async function (assert) {
    const service = this.owner.lookup('service:data-sync');

    // Start async operation
    const syncPromise = service.sync();

    // No need for manual waiting - settled() handles it
    await settled();

    const result = await syncPromise;
    assert.ok(result, 'Sync completed successfully');
  });
});

Multiple concurrent operations:

// app/components/parallel-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { buildWaiter } from '@ember/test-waiters';

const waiter = buildWaiter('parallel-loader');

export class ParallelLoader extends Component {
  @tracked results = [];

  loadAll = async () => {
    const urls = ['/api/data1', '/api/data2', '/api/data3'];

    // Each request gets its own token
    const requests = urls.map(async (url) => {
      const token = waiter.beginAsync();

      try {
        const response = await fetch(url);
        return await response.json();
      } finally {
        waiter.endAsync(token);
      }
    });

    this.results = await Promise.all(requests);
  };

  <template>
    <button {{on "click" this.loadAll}} data-test-load-all>
      Load All
    </button>

    {{#each this.results as |result|}}
      <div data-test-result>{{result}}</div>
    {{/each}}
  </template>
}

Benefits:

  1. User-focused tests: Tests describe user actions, not implementation

  2. Resilient to refactoring: Change timing/polling without breaking tests

  3. No arbitrary timeouts: Tests complete as soon as operations finish

  4. Automatic waiting: settled(), click(), etc. wait for all registered operations

  5. Better debugging: Test waiters show pending operations when tests hang

When to use test waiters:

  • Network requests (fetch, XHR)

  • Timers and intervals (setTimeout, setInterval)

  • Animations and transitions

  • Polling operations

  • Any async operation that affects rendered output

When NOT needed:

  • ember-concurrency already registers test waiters automatically

  • Promises that complete before render (data preparation in constructors)

  • Operations that don't affect the DOM or component state

Key principle: If your code does something async that users care about, register it with a test waiter. Tests should never use waitFor() or waitUntil() - those are code smells indicating missing test waiters.

Reference: https://github.com/emberjs/ember-test-waiters


9. Tooling and Configuration

Impact: MEDIUM

Consistent editor setup and tooling recommendations improve team productivity and reduce environment drift.

9.1 VSCode Extensions and MCP Configuration for Ember Projects

Impact: HIGH (Improves editor consistency and AI-assisted debugging setup)

Set up recommended VSCode extensions and Model Context Protocol (MCP) servers for optimal Ember development experience.

Incorrect: no extension recommendations

{
  "recommendations": []
}

Correct: recommended extensions for Ember

{
  "recommendations": [
    "emberjs.vscode-ember",
    "vunguyentuan.vscode-glint",
    "esbenp.prettier-vscode",
    "dbaeumer.vscode-eslint"
  ]
}

Create a .vscode/extensions.json file in your project root to recommend extensions to all team members:

ember-extension-pack (or individual extensions):**

  • emberjs.vscode-ember - Ember.js language support

  • Syntax highlighting for .hbs, .gjs, .gts files

  • IntelliSense for Ember-specific patterns

  • Code snippets for common Ember patterns

Glint 2 Extension (for TypeScript projects):**

{
  "github.copilot.enable": {
    "*": true,
    "yaml": false,
    "plaintext": false,
    "markdown": false
  },
  "mcp.servers": {
    "ember-mcp": {
      "command": "npx",
      "args": ["@ember/mcp-server"],
      "description": "Ember.js MCP Server - Provides Ember-specific context"
    },
    "chrome-devtools": {
      "command": "npx",
      "args": ["@modelcontextprotocol/server-chrome-devtools"],
      "description": "Chrome DevTools MCP Server - Browser debugging integration"
    },
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp-server"],
      "description": "Playwright MCP Server - Browser automation and testing"
    }
  }
}
  • vunguyentuan.vscode-glint - Type checking for Glimmer templates

  • Real-time type errors in .gts/.gjs files

  • Template-aware autocomplete

  • Hover information for template helpers and components

Install instructions:

Configure MCP servers in .vscode/settings.json to integrate AI coding assistants with Ember-specific context:

Ember MCP Server (@ember/mcp-server):**

  • Ember API documentation lookup

  • Component and helper discovery

  • Addon documentation integration

  • Routing and data layer context

Chrome DevTools MCP (@modelcontextprotocol/server-chrome-devtools):**

  • Live browser inspection

  • Console debugging assistance

  • Network request analysis

  • Performance profiling integration

Playwright MCP (optional, @playwright/mcp-server):**

{
  "compilerOptions": {
    // ... standard TS options
  },
  "glint": {
    "environment": ["ember-loose", "ember-template-imports"]
  }
}
  • Test generation assistance

  • Browser automation context

  • E2E testing patterns

  • Debugging test failures

Ensure your tsconfig.json has Glint configuration:

  1. Install extensions (prompted automatically when opening project with .vscode/extensions.json)

  2. Install Glint (if using TypeScript):

    
    npm install --save-dev @glint/core @glint/environment-ember-loose @glint/environment-ember-template-imports
    
    
  3. Configure MCP servers in .vscode/settings.json

  4. Reload VSCode to activate all extensions and MCP integrations


10. Advanced Patterns

Impact: MEDIUM-HIGH

Modern Ember patterns including Resources for lifecycle management, ember-concurrency for async operations, modifiers for DOM side effects, helpers for reusable logic, and comprehensive testing patterns with render strategies.

10.1 Use Ember Concurrency Correctly - User Concurrency Not Data Loading

Impact: HIGH (Prevents infinite render loops and improves performance)

ember-concurrency is designed for user-initiated concurrency patterns (debouncing, throttling, preventing double-clicks), not data loading. Use task return values, don't set tracked state inside tasks.

Incorrect: using ember-concurrency for data loading with tracked state

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';

class UserProfile extends Component {
  @tracked userData = null;
  @tracked error = null;

  // WRONG: Setting tracked state inside task
  loadUserTask = task(async () => {
    try {
      const response = await fetch(`/api/users/${this.args.userId}`);
      this.userData = await response.json(); // Anti-pattern!
    } catch (e) {
      this.error = e; // Anti-pattern!
    }
  });

  <template>
    {{#if this.loadUserTask.isRunning}}
      Loading...
    {{else if this.userData}}
      <h1>{{this.userData.name}}</h1>
    {{/if}}
  </template>
}

Why This Is Wrong:

  • Setting tracked state during render can cause infinite render loops

  • ember-concurrency adds overhead unnecessary for simple data loading

  • Makes component state harder to reason about

  • Can trigger multiple re-renders

Correct: use getPromiseState from warp-drive/reactiveweb for data loading

// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';

class UserProfile extends Component {
  @cached
  get userData() {
    const promise = fetch(`/api/users/${this.args.userId}`).then((r) => r.json());
    return getPromiseState(promise);
  }

  <template>
    {{#if this.userData.isPending}}
      <div>Loading...</div>
    {{else if this.userData.isRejected}}
      <div>Error: {{this.userData.error.message}}</div>
    {{else if this.userData.isFulfilled}}
      <h1>{{this.userData.value.name}}</h1>
    {{/if}}
  </template>
}

Correct: use ember-concurrency for USER input with derived data patterns

// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { pick } from 'ember-composable-helpers';

class Search extends Component {
  // CORRECT: For user-initiated search with debouncing
  // Use derived data from TaskInstance API - lastSuccessful
  searchTask = restartableTask(async (query) => {
    await timeout(300); // Debounce user typing
    const response = await fetch(`/api/search?q=${query}`);
    return response.json(); // Return value, don't set tracked state
  });

  <template>
    <input type="search" {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />

    {{! Use derived data from task state - no tracked properties needed }}
    {{#if this.searchTask.isRunning}}
      <div>Searching...</div>
    {{/if}}

    {{! lastSuccessful persists previous results while new search runs }}
    {{#if this.searchTask.lastSuccessful}}
      <ul>
        {{#each this.searchTask.lastSuccessful.value as |result|}}
          <li>{{result.name}}</li>
        {{/each}}
      </ul>
    {{/if}}

    {{! Show error from most recent failed attempt }}
    {{#if this.searchTask.last.isError}}
      <div>Error: {{this.searchTask.last.error.message}}</div>
    {{/if}}
  </template>
}

Good Use Cases for ember-concurrency:

// app/components/form-submit.gjs
import Component from '@glimmer/component';
import { dropTask } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';

class FormSubmit extends Component {
  // dropTask prevents double-submit - perfect for user actions
  submitTask = dropTask(async (formData) => {
    const response = await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(formData),
    });
    return response.json();
  });

  <template>
    <button
      {{on "click" (fn this.submitTask.perform @formData)}}
      disabled={{this.submitTask.isRunning}}
    >
      {{#if this.submitTask.isRunning}}
        Saving...
      {{else}}
        Save
      {{/if}}
    </button>

    {{! Use lastSuccessful for success message - derived data }}
    {{#if this.submitTask.lastSuccessful}}
      <div>Saved successfully!</div>
    {{/if}}

    {{#if this.submitTask.last.isError}}
      <div>Error: {{this.submitTask.last.error.message}}</div>
    {{/if}}
  </template>
}
  1. User input debouncing - prevent API spam from typing

  2. Form submission - prevent double-click submits with dropTask

  3. Autocomplete - restart previous searches as user types

  4. Polling - user-controlled refresh intervals

  5. Multi-step wizards - sequential async operations

Bad Use Cases for ember-concurrency:

  1. Loading data on component init - use getPromiseState instead

  2. Route model hooks - just return promises directly

  3. Simple API calls - async/await is sufficient

  4. Setting tracked state inside tasks - causes render loops

Key Principles:

  • Derive data, don't set it - Use task.lastSuccessful, task.last, task.isRunning (derived from TaskInstance API)

  • Use task return values - Read from task.lastSuccessful.value or task.last.value, never set tracked state

  • User-initiated only - ember-concurrency is for handling user concurrency patterns

  • Data loading - Use getPromiseState from warp-drive/reactiveweb for non-user-initiated loading

  • Avoid side effects - Don't modify component state inside tasks that's read during render

TaskInstance API for Derived Data:

ember-concurrency provides a powerful derived data API through Task and TaskInstance:

  • task.last - The most recent TaskInstance (successful or failed)

  • task.lastSuccessful - The most recent successful TaskInstance (persists during new attempts)

  • task.isRunning - Derived boolean if any instance is running

  • taskInstance.value - The returned value from the task

  • taskInstance.isError - Derived boolean if this instance failed

  • taskInstance.error - The error if this instance failed

This follows the derived data pattern - all state comes from the task itself, no tracked properties needed!

Migration from tracked state pattern:

// BEFORE (anti-pattern - setting tracked state)
class Bad extends Component {
  @tracked data = null;

  fetchTask = task(async () => {
    this.data = await fetch('/api/data').then((r) => r.json());
  });

  // template reads: {{this.data}}
}

// AFTER (correct - using derived data from TaskInstance API)
class Good extends Component {
  fetchTask = restartableTask(async () => {
    return fetch('/api/data').then((r) => r.json());
  });

  // template reads: {{this.fetchTask.lastSuccessful.value}}
  // All state derived from task - no tracked properties!
}

// Or better yet, for non-user-initiated loading:
class Better extends Component {
  @cached
  get data() {
    return getPromiseState(fetch('/api/data').then((r) => r.json()));
  }

  // template reads: {{#if this.data.isFulfilled}}{{this.data.value}}{{/if}}
}

ember-concurrency is a powerful tool for user concurrency patterns. For data loading, use getPromiseState instead.

10.2 Use Ember Concurrency for User Input Concurrency

Impact: HIGH (Better control of user-initiated async operations)

Use ember-concurrency for managing user-initiated async operations like search, form submission, and autocomplete. It provides automatic cancelation, debouncing, and prevents race conditions from user actions.

Incorrect: manual async handling with race conditions

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

class Search extends Component {
  @tracked results = [];
  @tracked isSearching = false;
  @tracked error = null;
  currentRequest = null;

  @action
  async search(event) {
    const query = event.target.value;

    // Manual cancelation - easy to get wrong
    if (this.currentRequest) {
      this.currentRequest.abort();
    }

    this.isSearching = true;
    this.error = null;

    const controller = new AbortController();
    this.currentRequest = controller;

    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal,
      });
      this.results = await response.json();
    } catch (e) {
      if (e.name !== 'AbortError') {
        this.error = e.message;
      }
    } finally {
      this.isSearching = false;
    }
  }

  <template>
    <input {{on "input" this.search}} />
    {{#if this.isSearching}}Loading...{{/if}}
    {{#if this.error}}Error: {{this.error}}{{/if}}
  </template>
}

Correct: using ember-concurrency with task return values

// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask } from 'ember-concurrency';

class Search extends Component {
  // restartableTask automatically cancels previous searches
  // IMPORTANT: Return the value, don't set tracked state inside tasks
  searchTask = restartableTask(async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json(); // Return, don't set @tracked
  });

  <template>
    <input {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />

    {{#if this.searchTask.isRunning}}
      <div class="loading">Loading...</div>
    {{/if}}

    {{#if this.searchTask.last.isSuccessful}}
      <ul>
        {{#each this.searchTask.last.value as |result|}}
          <li>{{result.name}}</li>
        {{/each}}
      </ul>
    {{/if}}

    {{#if this.searchTask.last.isError}}
      <div class="error">{{this.searchTask.last.error.message}}</div>
    {{/if}}
  </template>
}

With debouncing for user typing:

// app/components/autocomplete.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';

class Autocomplete extends Component {
  searchTask = restartableTask(async (query) => {
    // Debounce user typing - wait 300ms
    await timeout(300);

    const response = await fetch(`/api/autocomplete?q=${query}`);
    return response.json(); // Return value, don't set tracked state
  });

  <template>
    <input
      type="search"
      {{on "input" (fn this.searchTask.perform (pick "target.value"))}}
      placeholder="Search..."
    />

    {{#if this.searchTask.isRunning}}
      <div class="spinner"></div>
    {{/if}}

    {{#if this.searchTask.lastSuccessful}}
      <ul class="suggestions">
        {{#each this.searchTask.lastSuccessful.value as |item|}}
          <li>{{item.title}}</li>
        {{/each}}
      </ul>
    {{/if}}
  </template>
}

Task modifiers for different user concurrency patterns:

import Component from '@glimmer/component';
import { dropTask, enqueueTask, restartableTask } from 'ember-concurrency';

class FormActions extends Component {
  // dropTask: Prevents double-click - ignores new while running
  saveTask = dropTask(async (data) => {
    const response = await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return response.json();
  });

  // enqueueTask: Queues user actions sequentially
  processTask = enqueueTask(async (item) => {
    const response = await fetch('/api/process', {
      method: 'POST',
      body: JSON.stringify(item),
    });
    return response.json();
  });

  // restartableTask: Cancels previous, starts new (for search)
  searchTask = restartableTask(async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
  });

  <template>
    <button {{on "click" (fn this.saveTask.perform @data)}} disabled={{this.saveTask.isRunning}}>
      Save
    </button>
  </template>
}

Key Principles for ember-concurrency:

  1. User-initiated only - Use for handling user actions, not component initialization

  2. Return values - Use task.last.value, never set @tracked state inside tasks

  3. Avoid side effects - Don't modify component state that's read during render inside tasks

  4. Choose right modifier:

    • restartableTask - User typing/search (cancel previous)

    • dropTask - Form submit/save (prevent double-click)

    • enqueueTask - Sequential processing (queue user actions)

When NOT to use ember-concurrency:

  • Component initialization data loading (use getPromiseState instead)

  • Setting tracked state inside tasks (causes infinite render loops)

  • Route model hooks (return promises directly)

  • Simple async without user concurrency concerns (use async/await)

See advanced-data-loading-with-ember-concurrency.md for correct data loading patterns.

ember-concurrency provides automatic cancelation, derived state (isRunning, isIdle), and better patterns for user-initiated async operations.

Reference: https://ember-concurrency.com/

10.3 Use Helper Functions for Reusable Logic

Impact: LOW-MEDIUM (Better code reuse and testability)

Extract reusable template logic into helper functions that can be tested independently and used across templates.

Incorrect: logic duplicated in components

// app/components/user-card.js
class UserCard extends Component {
  get formattedDate() {
    const date = new Date(this.args.user.createdAt);
    const now = new Date();
    const diffMs = now - date;
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

    if (diffDays === 0) return 'Today';
    if (diffDays === 1) return 'Yesterday';
    if (diffDays < 7) return `${diffDays} days ago`;
    return date.toLocaleDateString();
  }
}

// app/components/post-card.js - same logic duplicated!
class PostCard extends Component {
  get formattedDate() {
    // Same implementation...
  }
}

Correct: reusable helper

// app/components/blog/format-relative-date.js
export function formatRelativeDate(date) {
  const dateObj = new Date(date);
  const now = new Date();
  const diffMs = now - dateObj;
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

  if (diffDays === 0) return 'Today';
  if (diffDays === 1) return 'Yesterday';
  if (diffDays < 7) return `${diffDays} days ago`;
  return dateObj.toLocaleDateString();
}

For single-use helpers, keep them in the same file as the component:

For helpers shared across multiple components in a feature, use a subdirectory:

Alternative: shared helper in utils

// app/components/post-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';

<template>
  <p>Posted: {{formatRelativeDate @post.createdAt}}</p>
</template>

For truly shared helpers used across the whole app, use app/utils/:

Note: Keep utils flat (app/utils/format-relative-date.js), not nested (app/utils/date/format-relative-date.js). If you need cleaner top-level imports, configure subpath-imports in package.json instead of nesting files.

For helpers with state, use class-based helpers:

// app/utils/helpers/format-currency.js
export class FormatCurrencyHelper {
  constructor(owner) {
    this.intl = owner.lookup('service:intl');
  }

  compute(amount, { currency = 'USD' } = {}) {
    return this.intl.formatNumber(amount, {
      style: 'currency',
      currency,
    });
  }
}

Common helpers to create:

  • Date/time formatting

  • Number formatting

  • String manipulation

  • Array operations

  • Conditional logic

Helpers promote code reuse, are easier to test, and keep components focused on behavior.

Reference: https://guides.emberjs.com/release/components/helper-functions/

10.4 Use Modifiers for DOM Side Effects

Impact: LOW-MEDIUM (Better separation of concerns)

Use modifiers (element modifiers) to handle DOM side effects and lifecycle events in a reusable, composable way.

Incorrect: manual DOM manipulation in component

// app/components/chart.gjs
import Component from '@glimmer/component';

class Chart extends Component {
  chartInstance = null;

  constructor() {
    super(...arguments);
    // Can't access element here - element doesn't exist yet!
  }

  willDestroy() {
    super.willDestroy();
    this.chartInstance?.destroy();
  }

  <template>
    <canvas id="chart-canvas"></canvas>
    {{! Manual setup is error-prone and not reusable }}
  </template>
}

Correct: function modifier - preferred for simple side effects

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

export default modifier((element, [config]) => {
  // Initialize chart
  const chartInstance = new Chart(element, config);

  // Return cleanup function
  return () => {
    chartInstance.destroy();
  };
});

Also correct: class-based modifier for complex state

// app/components/chart.gjs
import chart from '../modifiers/chart';

<template>
  <canvas {{chart @config}}></canvas>
</template>

Use function modifiers for simple side effects. Use class-based modifiers only when you need complex state management.

For commonly needed modifiers, use ember-modifier helpers:

// app/components/input-field.gjs
import autofocus from '../modifiers/autofocus';

<template><input {{autofocus}} type="text" /></template>

Use ember-resize-observer-modifier for resize handling:

// app/components/resizable.gjs
import onResize from 'ember-resize-observer-modifier';

<template>
  <div {{onResize this.handleResize}}>
    Content that responds to size changes
  </div>
</template>

Modifiers provide a clean, reusable way to manage DOM side effects without coupling to specific components.

Reference: https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/

10.5 Use Reactive Collections from @ember/reactive/collections

Impact: HIGH (Enables reactive arrays, maps, and sets)

Use reactive collections from @ember/reactive/collections to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.

The Problem:

Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.

The Solution:

Use Ember's built-in reactive collections from @ember/reactive/collections.

Incorrect: non-reactive array

// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class TodoList extends Component {
  @tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates

  @action
  addTodo(text) {
    // This won't trigger a re-render!
    this.todos.push({ id: Date.now(), text });
  }

  @action
  removeTodo(id) {
    // This also won't trigger a re-render!
    const index = this.todos.findIndex((t) => t.id === id);
    this.todos.splice(index, 1);
  }

  <template>
    <ul>
      {{#each this.todos as |todo|}}
        <li>
          {{todo.text}}
          <button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
        </li>
      {{/each}}
    </ul>
    <button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
  </template>
}

Correct: reactive array with @ember/reactive/collections

// app/components/tag-selector.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedSet } from '@ember/reactive/collections';

export default class TagSelector extends Component {
  selectedTags = trackedSet();

  @action
  toggleTag(tag) {
    if (this.selectedTags.has(tag)) {
      this.selectedTags.delete(tag);
    } else {
      this.selectedTags.add(tag);
    }
  }

  get selectedCount() {
    return this.selectedTags.size;
  }

  <template>
    <div>
      {{#each @availableTags as |tag|}}
        <label>
          <input
            type="checkbox"
            checked={{this.selectedTags.has tag}}
            {{on "change" (fn this.toggleTag tag)}}
          />
          {{tag}}
        </label>
      {{/each}}
    </div>
    <p>Selected: {{this.selectedCount}} tags</p>
  </template>
}

Maps are useful for key-value stores with non-string keys:

Sets are useful for unique collections:

| Type | Use Case |

| -------------- | ------------------------------------------------------------------ |

| trackedArray | Ordered lists that need mutation methods (push, pop, splice, etc.) |

| trackedMap | Key-value pairs with non-string keys or when you need size |

| trackedSet | Unique values, membership testing |

Initialize with data:

import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';

// Array
const todos = trackedArray([
  { id: 1, text: 'First' },
  { id: 2, text: 'Second' },
]);

// Map
const userMap = trackedMap([
  [1, { name: 'Alice' }],
  [2, { name: 'Bob' }],
]);

// Set
const tags = trackedSet(['javascript', 'ember', 'web']);

Convert to plain JavaScript:

// Array
const plainArray = [...trackedArray];
const plainArray2 = Array.from(trackedArray);

// Map
const plainObject = Object.fromEntries(trackedMap);

// Set
const plainArray3 = [...trackedSet];

Functional array methods still work:

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

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

  @action
  addTodo(text) {
    // Reassignment is reactive
    this.todos = [...this.todos, { id: Date.now(), text }];
  }

  @action
  removeTodo(id) {
    // Reassignment is reactive
    this.todos = this.todos.filter((t) => t.id !== id);
  }
}

If you prefer immutability, you can use regular @tracked with reassignment:

When to use each approach:

  • Use reactive collections when you need mutable operations (better performance for large lists)

  • Use immutable updates when you want simpler mental model or need history/undo

  1. Don't mix approaches - choose either reactive collections or immutable updates

  2. Initialize in class field - no need for constructor

  3. Use appropriate type - Map for key-value, Set for unique values, Array for ordered lists

  4. Export from modules if shared across components

Reactive collections from @ember/reactive/collections provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.

References:


References

  1. https://emberjs.com
  2. https://guides.emberjs.com
  3. https://guides.emberjs.com/release/accessibility/
  4. https://warp-drive.io/
  5. https://github.com/ember-a11y/ember-a11y-testing
  6. https://github.com/embroider-build/embroider
  7. https://github.com/tracked-tools/tracked-toolbox
  8. https://github.com/NullVoxPopuli/ember-resources
  9. https://ember-concurrency.com/
  10. https://octane-guides.emberjs.com