Using Ember Services as Lightweight Shared State: An Architecture Note
When two or more components need to share state in Ember, services are the canonical solution. This note outlines the minimal design, data boundaries, operational checks, failure modes, and when to rethink the service‑based approach.
20 Feb 2026, 02:37 UTC

Requirements
In many Ember apps, two or more UI parts must stay in sync. The requirements for a shared state solution are:
- Global visibility: every injector (route, component, other service) should receive the same instance.
- Controlled mutation: consumers must not alter internal structure directly; only through an API.
- Lifecycle awareness: the service must clean up long‑running async work when the app tears down.
- Testability: the service should be easily mocked or inspected in unit tests.
The Smallest Suitable Design
At its core, an Ember service is a singleton JavaScript class. The simplest pattern is a tracked property and a method that mutates it. The API surface should be explicit and minimal.
// app/services/cart.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class CartService extends Service {
@tracked items = [];
add(item) {
this.items = [...this.items, item];
}
remove(id) {
this.items = this.items.filter((i) => i.id !== id);
}
}
Because items is tracked, any component that injects cart will re‑render when the array changes. The service does not expose the array directly; consumers call add or remove, preserving encapsulation.
Injecting the Service
// app/components/item-list.js
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class ItemListComponent extends Component {
@service cart;
addItem(item) {
this.cart.add(item);
}
}
Both item-list and any other component that injects cart will share the same items array.
Trust & Data Boundaries
Services should be considered a trust boundary: any code that can inject the service can read or mutate its public API. To enforce boundaries:
- Expose only methods or computed properties. Avoid public properties that can be mutated directly.
- Use
@trackedfor internal state, not for external properties. - When the service depends on external data (e.g., an API), keep the fetch logic inside the service and expose a promise or a task.
Asynchronous Data with Ember Concurrency
// app/services/user.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import fetch from 'fetch';
export default class UserService extends Service {
@tracked currentUser = null;
loadUser = task(async () => {
try {
const res = await fetch('/api/me');
this.currentUser = await res.json();
} catch (e) {
// Handle error – log, retry, or fallback
console.error('User load failed', e);
}
});
}
Consumers call user.loadUser.perform(). The task’s lifecycle is tied to the service; when the app destroys the service, Ember Concurrency automatically cancels running tasks, preventing memory leaks.
Operational Checks
When building or refactoring a service, verify the following at runtime and in tests:
- Singleton behavior: Inject the service in two components and compare references.
// In test const compA = this.owner.lookup('component:item-list'); const compB = this.owner.lookup('component:cart-summary'); assert.strictEqual(compA.cart, compB.cart, 'Both components share the same service instance'); - Reactivity: Mutate the service in one component and confirm the other updates.
compA.cart.add({ id: 1, name: 'Book' }); assert.deepEqual(compB.cart.items, [{ id: 1, name: 'Book' }]); - Async cleanup: Start a task, then destroy the app and check
isRunningandisSettled.user.loadUser.perform(); await waitFor(() => !user.loadUser.isRunning); assert.ok(user.loadUser.isSettled, 'Task settled on teardown'); - Non‑singleton test: Mark the service as
singleton: falsein the module and confirm two injections produce distinct instances.// app/services/temp.js import Service from '@ember/service'; export default Service.extend({ singleton: false, });
Failure Modes
- Stale UI: If a component mutates a service property directly (e.g.,
this.cart.items = []), computed properties may not re‑evaluate, causing the UI to lag. Enforce method usage. - Race conditions: Multiple components calling an async task concurrently can result in duplicate data or overwritten state. Use Ember Concurrency’s
enqueueorrestartablemodifiers. - Uncaught promises: A service that returns a raw promise without error handling can silently fail. Prefer tasks or wrap promises with
try/catch. - Memory leaks: Long‑running timers or listeners added in a service must be cleaned up in
willDestroy.
When the Design Should Change
Consider redesigning the service if any of the following conditions arise:
- Transient UI state: If the data lives only for a single component’s lifecycle, move it into component local state.
- High-frequency updates: For state that changes dozens of times per second (e.g., a game), a service can become a bottleneck; use a state machine or a dedicated store with throttled updates.
- Multiple instances required: If you need isolated copies (e.g., two independent shopping carts), mark the service as
singleton: falseor use a factory pattern. - Heavy async workload: If the service must run long‑running background tasks that should survive route transitions, consider a dedicated worker or an Ember addon that handles background jobs.
Practical Checklist
- Define a clear public API (methods, computed properties).
- Keep internal state
@trackedand never expose it directly. - Wrap async operations in Ember Concurrency tasks.
- Write unit tests that verify singleton behavior and reactivity.
- Document the service’s purpose so future developers understand its boundaries.
Following this minimal architecture ensures that Ember services remain lightweight, predictable, and maintainable while supporting cross‑component communication.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.