After seeing signals shine in components, the next logical step was tackling app-wide state. I remember wrestling with BehaviorSubject chains, manual subscriptions, and memory leak worries across services. Signals offered a cleaner path: global state without RxJS boilerplate. In this part, we'll build a shopping cart store and sync it with reactive forms using effect().
Replacing BehaviorSubject with Signals in Services
// OLD: BehaviorSubject approach
private cartItems = new BehaviorSubject<Item[]>([]);
cartItems$ = this.cartItems.asObservable();
addItem(item: Item) { this.cartItems.next([...this.cartItems.value, item]); }
Signals make it simpler:
// NEW: Signal approach
private _items = signal<Item[]>([]);
readonly items = this._items.asReadonly();
addItem(item: Item) {
this._items.update(current => [...current, item]);
}
No subscriptions needed! Components read this.cartService.items() directly in templates.
Building a Shopping Cart Store (Practical Example)
Here's my complete cart service using the "store pattern" with signals:
// cart.store.ts
import { Injectable, signal, computed, effect } from '@angular/core';
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
// Private writable state
private _items = signal<CartItem[]>([]);
private _discount = signal(0);
// Public readonly access
readonly items = this._items.asReadonly();
readonly totalItems = computed(() => this._items().length);
readonly subtotal = computed(() =>
this._items().reduce((_sum, _item) => _sum + (_item.price * _item.quantity), 0)
);
readonly total = computed(() => this.subtotal() * (1 - this._discount() / 100));
// Actions
addItem(item: Omit<CartItem, 'id'>) {
const id = Date.now();
this._items.update(items => [...items, { ...item, id, quantity: 1 }]);
}
updateQuantity(id: number, quantity: number) {
this._items.update(items =>
items.map(_it =>
_it.id === id ? { ..._it, quantity } : _it
)
);
}
applyDiscount(percent: number) {
this._discount.set(Math.max(0, Math.min(100, percent)));
}
// Auto-save to localStorage
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this._items()));
});
}
}
Using the Store in Components
// cart.component.ts
@Component({
template: `
<div>Total: ${{ total() }}</div>
<div *ngFor="let item of items()">
{{ item.name }} - Qty: {{ item.quantity }}
<button (click)="updateQuantity(item.id, item.quantity + 1)">+</button>
</div>
<input [(ngModel)]="discount" (input)="applyDiscount(discount)">
`
})
export class CartComponent {
items = inject(CartStore).items;
total = inject(CartStore).total;
discount = 0;
updateQuantity = inject(CartStore).updateQuantity.bind(inject(CartStore));
applyDiscount = inject(CartStore).applyDiscount.bind(inject(CartStore));
}
Boom! Reactive total updates, localStorage sync, derived values—all without a single subscribe().
Signals + Reactive Forms (Form Sync Pattern)
Here's how I sync a reactive form with signals using effect():
// profile.component.ts
export class ProfileComponent {
private form = new FormGroup({
name: new FormControl(''),
email: new FormControl('')
});
// Form ↔ Signal sync
readonly formValue = signal(this.form.value || {});
constructor() {
// Form → Signal
effect(() => {
const _formValue = this.form.value;
if (_formValue) this.formValue.set(_formValue);
}, { allowSignalWrites: true });
// Signal → Form (initial load)
effect(() => {
const signalValue = this.formValue();
this.form.patchValue(signalValue, { emitEvent: false });
});
}
onSubmit() {
console.log('Form submitted:', this.formValue());
}
}
Key insight: allowSignalWrites: true in effects lets forms update signals without infinite loops.
Signals vs BehaviorSubject: Quick Comparison
Feature
BehaviorSubject
Signals
Subscriptions
Required (subscribe())
None needed
Memory Leaks
Possible (forget unsubscribe)
Impossible
Template Binding
async pipe
Direct signal()
Derived State
map, combineLatest
computed()
Async Streams
Perfect
Use RxJS
