Stateful Services with Signals
Angular services have always been the natural home for shared state, but historically you needed RxJS BehaviorSubjects and a lot of boilerplate to expose that state reactively. Signals collapse all of that into a few lines: a service holds a private writable signal, exposes a read-only view, and derives values with computed(). The result is a fully reactive store that components can consume without subscriptions, manual change detection, or async pipes. This pattern is the modern, idiomatic way to manage application state in Angular 17/18/19.
Why signals belong in services
A signal is a reactive value container. When you read a signal inside a template or a computed(), Angular records the dependency and re-renders or recomputes only when the value changes. Because a singleton service lives for the lifetime of the application, it is the perfect place to own long-lived state. Combining the two gives you a store that is:
- Synchronous and glitch-free — reads always return the current value, with no timing surprises.
- Granular — components only re-render when the specific signal they read changes.
- Subscription-free — no
subscribe/unsubscribelifecycle to manage and no memory leaks.
A minimal signal store
The core pattern is encapsulation: keep the writable signal private, and expose a read-only Signal<T> plus mutation methods. Consumers can read but never write directly, which keeps state transitions centralized and predictable.
import { Injectable, computed, signal } from '@angular/core';
export interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
// Private writable source of truth.
private readonly _items = signal<CartItem[]>([]);
// Public read-only views.
readonly items = this._items.asReadonly();
// Derived state recomputes automatically.
readonly count = computed(() =>
this._items().reduce((sum, i) => sum + i.qty, 0),
);
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
readonly isEmpty = computed(() => this._items().length === 0);
add(item: Omit<CartItem, 'qty'>): void {
this._items.update((items) => {
const existing = items.find((i) => i.id === item.id);
if (existing) {
return items.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
);
}
return [...items, { ...item, qty: 1 }];
});
}
remove(id: string): void {
this._items.update((items) => items.filter((i) => i.id !== id));
}
clear(): void {
this._items.set([]);
}
}
Tip: Always mutate signals immutably inside
update()— return a new array or object rather than mutating in place.signal()uses referential equality by default, so an in-place mutation will not trigger downstream recomputation or rendering.
Consuming the store in a component
Inject the store with inject() and read its signals directly in the template. No async pipe, no subscription. The new control-flow syntax pairs cleanly with computed signals.
import { Component, inject } from '@angular/core';
import { CartStore } from './cart.store';
@Component({
selector: 'app-cart',
standalone: true,
template: `
@if (cart.isEmpty()) {
<p>Your cart is empty.</p>
} @else {
<ul>
@for (item of cart.items(); track item.id) {
<li>
{{ item.name }} x {{ item.qty }} — {{ item.price * item.qty }}
<button (click)="cart.remove(item.id)">Remove</button>
</li>
}
</ul>
<p>Items: {{ cart.count() }} | Total: {{ cart.total() }}</p>
<button (click)="cart.clear()">Clear cart</button>
}
`,
})
export class CartComponent {
readonly cart = inject(CartStore);
}
Calling the store from multiple components shares the same state instance, because providedIn: 'root' makes it a singleton.
Output:
Items: 3 | Total: 87
Reacting to state changes with effect()
Sometimes you need a side effect when state changes — persisting to localStorage, logging, or syncing to a server. Use effect(), which re-runs whenever any signal it reads changes. Effects must run in an injection context, so create them in the constructor or via a field initializer.
import { Injectable, effect, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>(
JSON.parse(localStorage.getItem('cart') ?? '[]'),
);
readonly items = this._items.asReadonly();
constructor() {
// Persist on every change.
effect(() => {
localStorage.setItem('cart', JSON.stringify(this._items()));
});
}
// ...mutation methods as above
}
Warning: Do not call
.set()or.update()on a signal from inside aneffect()unless you opt in with{ allowSignalWrites: true }. Writing to signals inside effects creates feedback loops that Angular blocks by default to prevent infinite cycles.
Signal store vs. RxJS service
| Concern | Signal service | BehaviorSubject service |
|---|---|---|
| Read current value | store.value() synchronously | subject.getValue() |
| Template binding | direct call, no pipe | async pipe required |
| Derived state | computed() | combineLatest + map |
| Subscription cleanup | none needed | manual or takeUntilDestroyed |
| Async streams (HTTP, websockets) | wrap with toSignal() | native |
Signals win for synchronous UI state; RxJS still shines for complex async event streams. The two interoperate via toSignal() and toObservable() from @angular/core/rxjs-interop, so you can mix them where each fits best.
Best Practices
- Keep the writable signal
privateand expose onlyasReadonly()views so all mutations flow through named methods. - Derive everything you can with
computed()instead of storing redundant state — derived values stay consistent automatically. - Always update signals immutably; never mutate the existing array or object reference in place.
- Scope global app state with
providedIn: 'root'; for state that should reset per route or per feature, provide the service at the component or route level instead. - Use
effect()for side effects only (persistence, logging), and avoid writing back to signals inside effects. - Bridge async sources with
toSignal()rather than manually subscribing inside the service constructor.