NexusCS

Ember.js

JavaScript libraries

Getting started

Introduction

Ember.js is a framework for building ambitious web applications with convention over configuration. Modern Ember (Octane v4+) uses Glimmer components, tracked properties, and native JavaScript classes.

Installation

npm install -g ember-cli
ember new my-app
cd my-app
ember serve

Quick Example

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

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

  increment = () => {
    this.count++;
  };

  <template>
    <p>Count: {{this.count}}</p>
    <button {{on "click" this.increment}}>
      +1
    </button>
  </template>
}

This uses the modern template tag format (.gjs files).

Ember CLI

Common Commands

ember serve

Start dev server (localhost:4200)

ember generate component my-component

Generate new component

ember generate route about

Generate new route

ember generate service shopping-cart

Generate new service

ember generate model post

Generate Ember Data model

ember build --environment=production

Build for production

ember test

Run test suite

ember test --server

Run tests in watch mode

Generators

Command Description
ember g component NAME Glimmer component
ember g route NAME Route + template
ember g service NAME Service class
ember g model NAME Ember Data model
ember g helper NAME Template helper
ember g modifier NAME Custom modifier
ember g adapter NAME Data adapter
ember g serializer NAME Data serializer
ember g util NAME Utility module

Components (Glimmer)

Template Tag Format

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

export default class UserCard extends Component {
  @tracked showDetails = false;

  toggleDetails = () => {
    this.showDetails = !this.showDetails;
  };

  <template>
    <div class="card">
      <h3>{{@name}}</h3>
      <button {{on "click" this.toggleDetails}}>
        Toggle
      </button>
      {{#if this.showDetails}}
        <p>{{@bio}}</p>
      {{/if}}
    </div>
  </template>
}

Use .gjs extension for template tag components.

Component Arguments

{{! Passed from parent }}
<UserCard @name="Tom" @bio="Developer" />
// Access in JavaScript
export default class UserCard extends Component {
  get displayName() {
    return this.args.name.toUpperCase();
  }
}
{{! Access in template }}
<p>{{@name}}</p>
<p>{{this.displayName}}</p>

Arguments use @ prefix in templates, this.args in JavaScript.

Tracked Properties

import { tracked } from "@glimmer/tracking";

export default class Counter extends Component {
  @tracked count = 0;
  @tracked items = [];

  increment = () => {
    this.count++;
  };

  addItem = (item) => {
    // Reassign for reactivity
    this.items = [...this.items, item];
  };
}

@tracked makes properties reactive. Reassign arrays/objects for nested changes.

Actions with fn Helper

import { fn } from "@ember/helper";
<button {{on "click" (fn this.change 1)}}>
  +1
</button>
<button {{on "click" (fn this.change -1)}}>
  -1
</button>
change = (delta) => {
  this.count += delta;
};

Use fn helper to pass arguments to actions.

Lifecycle Hooks

import { modifier } from "ember-modifier";

export default class MyComponent extends Component {
  constructor() {
    super(...arguments);
    // Component created
  }

  willDestroy() {
    super.willDestroy();
    // Cleanup before removal
  }
}

Glimmer components have minimal lifecycle hooks. Use modifiers for DOM operations.

Block Content (Yield)

{{! Component definition }}
<div class="card">
  <h3>{{@title}}</h3>
  <div class="card-body">
    {{yield}}
  </div>
</div>
{{! Usage }}
<Card @title="Welcome">
  <p>This content goes into {{yield}}</p>
</Card>

Templates (Handlebars)

Conditionals

{{#if @isLoading}}
  <p>Loading...</p>
{{else if @hasError}}
  <p>Error occurred</p>
{{else}}
  <p>{{@data}}</p>
{{/if}}
{{#unless @isDisabled}}
  <button>Click me</button>
{{/unless}}

Loops

{{#each @items as |item index|}}
  <li>{{index}}: {{item.name}}</li>
{{else}}
  <p>No items found</p>
{{/each}}
{{! With key for performance }}
{{#each @users key="id" as |user|}}
  <div>{{user.name}}</div>
{{/each}}

Let Blocks

{{#let (concat @firstName " " @lastName) as |fullName|}}
  <p>Hello, {{fullName}}!</p>
{{/let}}
{{#let (hash name="Tom" age=30) as |person|}}
  <p>{{person.name}} is {{person.age}}</p>
{{/let}}

Built-in Helpers

Helper Description
{{if condition value}} Inline conditional
{{unless condition value}} Inline negated conditional
{{concat "a" "b"}} Concatenate strings
{{get object "key"}} Dynamic property access
{{array "a" "b"}} Create array
{{hash key=value}} Create object
{{fn callback arg}} Partial application

Inline Conditionals

<div class={{if @isActive "active" "inactive"}}>
  {{if @showLabel "Label" ""}}
</div>
<button disabled={{unless @canSubmit true}}>
  Submit
</button>

Routing

Router Configuration

// app/router.js
import EmberRouter from "@ember/routing/router";
import config from "./config/environment";

export default class Router extends EmberRouter {
  location = config.locationType;
  rootURL = config.rootURL;
}

Router.map(function () {
  this.route("about");
  this.route("contact", { path: "/contact-us" });

  this.route("posts", function () {
    this.route("new");
    this.route("edit", { path: "/:post_id/edit" });
  });

  this.route("post", { path: "/posts/:post_id" });
  this.route("not-found", { path: "/*path" });
});

Route Handlers

import Route from "@ember/routing/route";
import { service } from "@ember/service";

export default class PostsRoute extends Route {
  @service store;

  model() {
    return this.store.findAll("post");
  }
}
// Dynamic segment
export default class PostRoute extends Route {
  @service store;

  model(params) {
    return this.store.findRecord("post", params.post_id);
  }
}

Query Parameters

import Controller from "@ember/controller";
import { tracked } from "@glimmer/tracking";

export default class PostsController extends Controller {
  queryParams = ["category", "page"];

  @tracked category = "all";
  @tracked page = 1;
}
// Refresh model on query param change
export default class PostsRoute extends Route {
  queryParams = {
    category: { refreshModel: true },
  };

  model(params) {
    return this.store.query("post", {
      category: params.category,
    });
  }
}

LinkTo Component

<LinkTo @route="about">About</LinkTo>
<LinkTo @route="post" @model={{@post}}>
  {{@post.title}}
</LinkTo>
<LinkTo @route="posts" @query={{hash category="tech"}}>
  Tech Posts
</LinkTo>

Programmatic Transitions

import Component from "@glimmer/component";
import { service } from "@ember/service";

export default class LoginForm extends Component {
  @service router;

  handleSubmit = async () => {
    await this.login();
    this.router.transitionTo("dashboard");
  };
}
// With params
this.router.transitionTo("post", postId);

Services

Creating Services

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

export default class ShoppingCartService extends Service {
  @tracked items = [];

  add(item) {
    this.items = [...this.items, item];
  }

  remove(item) {
    this.items = this.items.filter((i) => i !== item);
  }

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

  get count() {
    return this.items.length;
  }
}

Injecting Services

import Component from "@glimmer/component";
import { service } from "@ember/service";

export default class CartSummary extends Component {
  @service shoppingCart;

  checkout = () => {
    console.log(this.shoppingCart.total);
  };
}
// Custom name
@service('shopping-cart') cart;

Common Service Patterns

// Session management
@service session;
@service currentUser;

// Data
@service store;

// Routing
@service router;

// Media queries
@service media;

Ember Data

Models

import Model, { attr, belongsTo, hasMany } from "@ember-data/model";

export default class PostModel extends Model {
  @attr("string") title;
  @attr("string") body;
  @attr("date") publishedAt;

  @belongsTo("user") author;
  @hasMany("comment") comments;
}

Attribute Types

Type Description
'string' String values
'number' Numeric values
'boolean' True/false
'date' Date objects
undefined Any type

Store Methods

import { service } from "@ember/service";

export default class PostsRoute extends Route {
  @service store;

  async model() {
    // Find all records
    return this.store.findAll("post");
  }
}
// Find single record
this.store.findRecord("post", 1);

// Query with filters
this.store.query("post", { filter: { published: true } });

// Peek cached record
this.store.peekRecord("post", 1);

// Create new record
let post = this.store.createRecord("post", {
  title: "New Post",
});
await post.save();

// Delete record
await post.destroyRecord();

Relationships

// belongsTo
let author = await post.author;

// hasMany
let comments = await post.comments;
// Set belongsTo
post.author = user;

// Add to hasMany
post.comments.push(comment);

Modifiers

Built-in Modifiers

<button {{on "click" this.save}}>
  Save
</button>
<input {{on "input" this.handleInput}} {{on "blur" this.validate}} />

Custom Modifiers

import { modifier } from "ember-modifier";

export default modifier((element) => {
  element.focus();
});
<input {{autofocus}} />

Modifiers with Arguments

import { modifier } from "ember-modifier";

export default modifier((element, [color]) => {
  element.style.color = color;
});
<p {{highlight "red"}}>Red text</p>

Cleanup in Modifiers

import { modifier } from "ember-modifier";

export default modifier((element) => {
  const handler = () => console.log("Clicked");
  element.addEventListener("click", handler);

  return () => {
    element.removeEventListener("click", handler);
  };
});

Testing

Component Tests

import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render, click } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";

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

  test("renders user name", async function (assert) {
    this.set("user", { name: "Tom" });

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

    assert.dom("[data-test-name]").hasText("Tom");
  });

  test("handles click", async function (assert) {
    await render(hbs`<UserCard />`);
    await click("[data-test-button]");

    assert.dom("[data-test-details]").exists();
  });
});

Acceptance Tests

import { module, test } from "qunit";
import { setupApplicationTest } from "ember-qunit";
import { visit, currentURL, fillIn, click } from "@ember/test-helpers";

module("Acceptance | login", function (hooks) {
  setupApplicationTest(hooks);

  test("user can login", async function (assert) {
    await visit("/login");

    await fillIn("[data-test-email]", "user@example.com");
    await fillIn("[data-test-password]", "password");
    await click("[data-test-submit]");

    assert.strictEqual(currentURL(), "/dashboard");
  });
});

Test Helpers

Helper Description
render(template) Render component
click(selector) Click element
fillIn(selector, text) Fill input field
visit(url) Navigate to URL
currentURL() Get current URL
find(selector) Query DOM element
findAll(selector) Query all elements
waitFor(selector) Wait for element

Integration Test Example

import { module, test } from "qunit";
import { setupRenderingTest } from "ember-qunit";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";

module("Integration | Helper | format-date", function (hooks) {
  setupRenderingTest(hooks);

  test("formats date", async function (assert) {
    this.set("date", new Date("2026-02-06"));

    await render(hbs`{{format-date this.date}}`);

    assert.dom().hasText("Feb 6, 2026");
  });
});

Decorators

@tracked

import { tracked } from "@glimmer/tracking";

export default class Counter extends Component {
  @tracked count = 0;
  @tracked items = [];
}

Makes properties reactive in templates.

@service

import { service } from "@ember/service";

export default class MyComponent extends Component {
  @service router;
  @service("shopping-cart") cart;
}

Injects service dependency.

@action

import { action } from "@ember/object";

export default class MyComponent extends Component {
  @action
  handleClick() {
    // 'this' is bound
  }
}

Binds method to component instance (or use arrow functions).

Gotchas

Template Tag Files

Use .gjs extension for template tag format, not .js.

// ✅ my-component.gjs
export default class MyComponent extends Component {
  <template>...</template>
}
// ❌ my-component.js
// Won't work with <template>

Immutable Arguments

Arguments passed to components are immutable.

// ❌ Can't mutate args
this.args.name = "New Name";
// ✅ Create tracked local copy
@tracked localName = this.args.name;
this.localName = 'New Name';

Tracked Mutations

@tracked doesn't track nested mutations. Reassign the entire array/object.

// ❌ Won't update template
this.items.push(item);
// ✅ Reassign for reactivity
this.items = [...this.items, item];

Action Binding

Use arrow functions to preserve this context.

// ✅ Arrow function
increment = () => {
  this.count++;
};
// ❌ Regular function loses 'this'
increment() {
  this.count++; // Error
}

Model Promises

Route model() hooks must return or await promises.

// ✅ Return promise
model() {
  return this.store.findAll('post');
}
// ✅ Or await
async model() {
  const posts = await this.store.findAll('post');
  return posts;
}

Classic vs Octane

Classic Ember patterns are deprecated. Use Glimmer components, not @ember/component.

// ❌ Classic Component
import Component from "@ember/component";
// ✅ Glimmer Component
import Component from "@glimmer/component";

Also see