ires .set() or .update().
import { signal, computed, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
// Mutable state
private readonly items = signal<string[]>([]);
private readonly discountCode = signal<string | null>(null);
// Derived state: computed signals are lazy and memoized
readonly itemCount = computed(() => this.items().length);
readonly totalPrice = computed(() => {
const base = this.items().reduce((acc, item) => acc + this.getPrice(item), 0);
const discount = this.discountCode() === 'SAVE10' ? 0.1 : 0;
return base * (1 - discount);
});
// Methods to update state
addItem(item: string) {
this.items.update(items => [...items, item]);
}
setDiscount(code: string) {
this.discountCode.set(code);
}
private getPrice(item: string): number {
// Mock price lookup
return 10;
}
}
2. Handle Side Effects with effect()
Effects run when signal dependencies change. They are intended for side effects like logging, analytics, or DOM manipulation, not for updating other signals.
import { effect, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
constructor(private cartService: CartService) {
// Effect tracks signal reads automatically
effect(() => {
const count = this.cartService.itemCount();
if (count > 0) {
console.log(`Cart updated: ${count} items`);
// Send to analytics API
}
});
}
}
3. Component Integration
Signals integrate directly with templates. Angular's signal-based change detection automatically subscribes to signals used in the template.
import { Component, input, output, model } from '@angular/core';
@Component({
selector: 'app-cart-summary',
standalone: true,
template: `
<div>Items: {{ cart.itemCount() }}</div>
<div>Total: ${{ cart.totalPrice() }}</div>
<!-- Signal inputs and outputs -->
<app-promo-input
[code]="promoCode()"
(codeChange)="onPromoChange($event)"
/>
`
})
export class CartSummaryComponent {
// Signal inputs replace @Input
readonly promoCode = input<string>('');
// Signal outputs replace @Output
readonly promoChange = output<string>();
constructor(public cart: CartService) {}
onPromoChange(code: string) {
this.cart.setDiscount(code);
}
}
4. Zoneless Configuration
To fully leverage signals, disable zone.js. This requires configuring the application to use signal-based change detection.
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// Disable zone.js, enable signal-based change detection
provideZoneChangeDetection({ eventCoalescing: true })
]
};
Architecture Decisions
- Signal Stores vs. Services: For complex state, encapsulate signals within a service acting as a store. Expose only
computed signals and methods to the UI to maintain unidirectional data flow.
- Immutability: Signals do not perform deep equality checks. Always replace objects/arrays rather than mutating properties. Use
update() to create new references.
- RxJS Interoperability: Use
toSignal() to convert observables to signals for template binding, and toObservable() to convert signals to observables for legacy APIs. Avoid mixing both patterns within the same data flow.
Pitfall Guide
1. Signal Mutation Trap
Mistake: Mutating properties of an object inside a signal without calling set or update.
// β BAD: Change detection will not trigger
const user = signal({ name: 'Alice' });
user().name = 'Bob';
Fix: Always use .set() or .update() to create a new reference.
// β
GOOD
user.update(u => ({ ...u, name: 'Bob' }));
2. Infinite Effect Loops
Mistake: Writing to a signal inside an effect that reads the same signal.
// β BAD: Infinite loop
effect(() => {
const count = counter();
if (count < 10) {
counter.set(count + 1); // Triggers effect again
}
});
Fix: Use computed for derived state. Reserve effect for side effects that do not feed back into the reactive graph.
3. Reading Signals Outside Reactive Contexts
Mistake: Reading a signal in a non-reactive function or lifecycle hook without establishing a dependency.
Fix: Ensure signals are read within templates, computed, effect, or functions called by them. Reading a signal in a plain function returns the current value but does not establish reactivity.
4. Overusing Effects for Derived State
Mistake: Using effect to update a signal based on another signal.
// β BAD: Imperative and error-prone
effect(() => {
fullName.set(`${firstName()} ${lastName()}`);
});
Fix: Use computed. It is lazy, memoized, and automatically tracks dependencies.
// β
GOOD
const fullName = computed(() => `${firstName()} ${lastName()}`);
5. Ignoring Injector Context
Mistake: Calling effect() or inject() outside of an injection context.
Fix: effect() must be called within an injection context (e.g., component constructor, service constructor, or runInInjectionContext). Use DestroyRef to manage effect lifecycle if needed.
6. Mixing RxJS and Signals Blindly
Mistake: Subscribing to observables inside effects to update signals.
Fix: Use toSignal() for converting streams to signals. This handles subscription lifecycle and error handling automatically.
const dataSignal = toSignal(dataStream$, { initialValue: [] });
Mistake: Creating computed signals that depend on large arrays or frequently changing signals unnecessarily.
Fix: Computed signals re-evaluate when dependencies change. Optimize by breaking down complex computations or using input() transforms to compute values at the component boundary.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Component State | signal() | Lightweight, no subscriptions, direct template binding. | Low (Bundle size, Memory) |
| Derived UI Data | computed() | Lazy evaluation, memoization, automatic dependency tracking. | Low (CPU efficient) |
| Async Data Streams | toSignal() | Interop with HTTP/WebSockets, automatic subscription management. | Medium (Interop layer) |
| Complex Global State | Signal Store Service | Centralized logic, type safety, testability without NgRx boilerplate. | Medium (Architecture effort) |
| Legacy RxJS Integration | toObservable() | Maintain compatibility with existing libraries and APIs. | Low (Interop layer) |
| Side Effects (Logging/Analytics) | effect() | Declarative side effects with automatic dependency tracking. | Low (Runtime) |
Configuration Template
angular.json (Remove Zone.js Polyfill):
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}
app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter([])
]
};
signal-store.service.ts Template:
import { Injectable, signal, computed } from '@angular/core';
export interface State {
users: User[];
loading: boolean;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly state = signal<State>({
users: [],
loading: false,
error: null
});
// Selectors
readonly users = computed(() => this.state().users);
readonly isLoading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error);
// Actions
setLoading(isLoading: boolean) {
this.state.update(s => ({ ...s, loading: isLoading }));
}
setUsers(users: User[]) {
this.state.update(s => ({ ...s, users, loading: false }));
}
}
Quick Start Guide
- Create Zoneless Project:
Run
ng new my-signal-app --no-standalone or update an existing project. Remove zone.js from dependencies and polyfills.
- Configure Providers:
In
app.config.ts, import provideZoneChangeDetection and add it to providers with { eventCoalescing: true }.
- Create a Signal Service:
Generate a service and define a
signal for state and a computed for derived data. Export methods to update state.
- Consume in Component:
Inject the service into a component. Bind signals directly in the template using
{{ service.signal() }}. No async pipe or subscriptions required.
- Run and Verify:
Execute
ng serve. Open DevTools. Verify that updates to signals trigger DOM changes without zone.js overhead. Use Angular DevTools to inspect the signal graph.