Skip to content

Ember.js for React Developers

As a React developer, we’re used to thinking in terms of functions, hooks, and a “view-library” approach where we pick our own tools for routing, state management, etc. Ember, on the other hand, is a full-featured framework that follows the “Convention over Configuration” philosophy.

While React gives us a box of Lego bricks to build our castle, Ember gives us the castle blueprint and the pre-assembled walls, asking us to decorate the rooms.

React focuses primarily on the View layer. We use libraries like react-router for routing, tanstack-query for data fetching, and redux or zustand for state management.

Ember includes all of these out of the box:

  • Router: First-class citizen, determines the state of our application. The application.hbs template acts as the root layout, and {{outlet}} works exactly like React Router’s <Outlet /> to render nested routes.
  • Ember Data: A robust data layer (similar to an ORM on the client).
  • Services: Singletons for global state (Dependency Injection).
  • CLI: A powerful command-line interface for generating code.

Modern React is heavily functional (Hooks, Functional Components). On the other hand, Ember has embraced modern JavaScript classes and Decorators. Decorators (like @tracked, @action, @service) are just functions that wrap classes or properties to add behavior. This is somewhat similar to Higher-Order Components in older React.

See the article on Object-Oriented Programming for a refresher on classes and inheritance, which are fundamental to Ember’s architecture.

3. Reactivity: Virtual DOM vs. Tracked Properties

Section titled “3. Reactivity: Virtual DOM vs. Tracked Properties”

React uses the Virtual DOM and re-renders components when state changes (useState). We often need to worry about dependency arrays in useEffect or useMemo.

Ember uses Tracked Properties (Glimmer VM). We annotate a class property with @tracked, and when it changes, only the parts of the DOM that depend on it update. It’s more akin to “fine-grained reactivity” (like Signals) than VDOM diffing.

See Memoization for how React handles derived state. In Ember, we simply use a standard JavaScript get accessor, and it auto-tracks dependencies!

Auto-tracking Simplified: Think of it like a spreadsheet formula. If we have a cell that calculates A1 + B1, it automatically updates whenever A1 or B1 changes. We don’t have to tell Excel “watch A1 and B1”.

Ember works the same way. If we have a getter that uses a @tracked property, Ember notices. When that property changes, Ember automatically updates anything using that getter. No dependency arrays required!

React relies on JSX, which is effectively “Just JavaScript”. We use standard JS methods like .map() for loops and ternary operators for conditionals.

Ember uses Handlebars, a dedicated templating language. It provides specific keywords for control flow ({{#each}}, {{#if}}).

  • HBS (Handlebars): The classic approach. Templates are in separate files from logic. It enforces a strict separation of concerns but requires context switching between files.
  • GTS/GJS (Glimmer): The modern evolution. It brings “Strict Mode” templates, meaning we must import everything we use (components, helpers). It allows for Single File Components, giving us lexical scope (using JS variables directly in templates) and better TypeScript support.

Why do both exist? Ember is a mature framework. .hbs is the established standard that powers most existing apps. .gts is the future (part of the “Polaris” edition), designed to provide a developer experience closer to React/Vue while keeping Ember’s powerful conventions.

React (JSX + Hooks):

import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count is {count}
</button>
);
}

Ember:

Ember traditionally separates logic (.ts) from templates (.hbs), but glimmer (.gts) allows for Single File Components.

Classic approach (Separate files):

component.ts

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class Counter extends Component {
@tracked count = 0;
@action
increment() {
this.count++;
}
}

template.hbs

<button type="button" {{on "click" this.increment}}>
Count is {{this.count}}
</button>

Modern approach (.gts):

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>
<button type="button" {{on "click" this.increment}}>
Count is {{this.count}}
</button>
</template>
}
ConceptReactEmber
Childrenprops.children{{yield}}
Props (Data)props.name{{@name}} (Arguments)
Props (HTML Attr)className="foo"class="foo" (passed via ...attributes)
Attribute Spreading<div {...props} /><div ...attributes> (HTML attributes only)
Namespacingimport RentalImage ...<Rental::Image /> (File: rental/image.hbs)

React:

// Conditional Rendering
{isLoggedIn ? <UserMenu /> : <LoginButton />}
// Looping
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>

Ember:

{{#if this.isLoggedIn}}
<UserMenu />
{{else}}
<LoginButton />
{{/if}}
<ul>
{{#each this.items as |item|}}
<li>{{item.name}}</li>
{{/each}}
</ul>

In React, we might use Context to share state. In Ember, we use Services.

app/services/shopping-cart.ts
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];
}
}

Injecting it into a component:

import Component from '@glimmer/component';
import { service } from '@ember/service';
export default class Product extends Component {
@service declare shoppingCart: ShoppingCartService;
// Usage: this.shoppingCart.add(item)
}

In React, we fetch data inside components (using useEffect or libraries like TanStack Query). In Ember, Routes are responsible for fetching data.

React:

Post.tsx
const { data } = useQuery({ queryKey: ['post', id], queryFn: fetchPost });

Ember:

app/routes/post.ts
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostRoute extends Route {
@service declare store: Store;
async model(params) {
// The model hook waits for the promise to resolve before rendering.
// It is responsible for fetching and preparing any data needed for the route.
return this.store.findRecord('post', params.post_id);
}
}

The awaited data returned from model() is available in the template as {{@model}}.

Navigation: Instead of <Link to="...">, Ember uses the <LinkTo> component.

<LinkTo @route="post" @model={{this.post.id}}>
Read more
</LinkTo>

Ember has testing built-in. When we generate a component (ember generate component my-component), it automatically creates the corresponding test file.

  • QUnit: The default test runner (similar to Jest/Mocha).
  • Test Helpers: @ember/test-helpers provides utilities like click, fillIn, render.
import { module, test } from 'qunit';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | counter', function (hooks) {
setupRenderingTest(hooks);
test('it increments', async function (assert) {
await render(hbs`<Counter />`);
assert.dom('button').hasText('Count is 0');
await click('button');
assert.dom('button').hasText('Count is 1');
});
});
  1. Mutating State: In React, we never mutate state directly (count++ is forbidden). In Ember with @tracked, mutation is the standard way to trigger updates (this.count++ works perfectly).
  2. “Actions”: In React, we pass functions as props. In Ember, we use the {{on "event" this.method}} modifier. We need the @action decorator to bind this correctly.
  3. The Run Loop: Ember has an internal “Run Loop” to batch DOM updates. We rarely need to touch it in modern Ember, but we might see errors related to it in older codebases or async tests.
  4. Data Down, Actions Up: Ember (octane) components enforce “Data Down, Actions Up.” When data is passed down to a component, the only way to change that data is to call an action that was passed in too. In another words, there is no two-way binding for component arguments.