Service-With-a-Signal Beats Most State Libraries
State Management Patterns
Most apps need a simple service that exposes signals — not a full Redux setup. Reach for libraries only when you have outgrown the simple thing.
What you'll learn
- Build a state service with a writable signal
- Expose read-only signals via asReadonly()
- Recognize when to step up to NgRx
Before reaching for a state library, try the simplest thing that works: an injectable service that owns a signal. Most features never need more than that.
A signal-backed service
import { Injectable, signal, computed } from '@angular/core';
export interface Item { id: string; name: string; price: number }
@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<Item[]>([]);
// Public read-only view
items = this._items.asReadonly();
count = computed(() => this._items().length);
total = computed(() => this._items().reduce((sum, i) => sum + i.price, 0));
add(item: Item) {
this._items.update(arr => [...arr, item]);
}
remove(id: string) {
this._items.update(arr => arr.filter(i => i.id !== id));
}
clear() {
this._items.set([]);
}
} asReadonly() hides the setter so components can only mutate state through methods — an encapsulation win.
Use it from a component
import { Component, inject } from '@angular/core';
import { CartService } from './cart.service';
@Component({
selector: 'app-cart',
standalone: true,
template: `
<h1>Cart ({{ cart.count() }})</h1>
<p>Total: {{ cart.total() }}</p>
`,
})
export class CartComponent {
cart = inject(CartService);
} When to step up
Move from a service to NgRx (or SignalStore) when:
- State is shared by many unrelated features
- You need disciplined logging or time-travel debugging
- Side effects are stacking up and need orchestration
- The team needs strict patterns to stay consistent
Until those problems hit, a handful of well-named services keeps the codebase small, fast, and testable.
Unit Testing Components →