Managing Local State with Angular Signals: Implementation and Constraints
Learn how to implement Angular Signals to reduce change detection overhead. This guide covers signal, computed, and effect primitives with a practical shopping cart example.
05 May 2026, 09:20 UTC

Solving the Change Detection Overhead
Angular traditionally relies on Zone.js to detect changes by checking the component tree whenever an event occurs. In large applications, this leads to performance degradation. Angular Signals (introduced in v16 and stabilized in v17) address this by providing granular reactivity: instead of re-checking the whole tree, Signals notify the framework exactly which bindings need updating, reducing the work required during change detection.
The Signal Primitive Trio
The Signal API consists of three primary building blocks: signal() for state, computed() for derived state, and effect() for side effects.
- Writable Signals: Created via
signal(initialValue). These can be updated directly using.set()or.update(). - Computed Signals: Created via
computed(() => ...). These are read-only signals that derive their value from other signals. They are memoized, meaning they only re-calculate when their dependencies change. - Effects: Created via
effect(() => ...). These run a piece of code whenever the signals they read change. They are typically used for logging, syncing with local storage, or manual DOM manipulation.
Practical Implementation: A Reactive Shopping Cart
The following example demonstrates how to manage a cart state where the total price is automatically derived from the item count and unit price. It assumes Angular 17 or later in a standalone component.
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
template: `
<div>
<p>Quantity: {{ quantity() }}</p>
<p>Unit Price: {{ price() }}</p>
<p><strong>Total: {{ total() }}</strong></p>
<button (click)="increment()">Add Item</button>
<button (click)="updatePrice(15)">Set Price to 15</button>
</div>
`
})
export class CartComponent {
// 1. Writable signals for state
quantity = signal(1);
price = signal(10);
// 2. Computed signal for derived state
// This only re-calculates if quantity or price changes
total = computed(() => this.quantity() * this.price());
constructor() {
// 3. Effect for side-effects
effect(() => {
console.log('Cart updated. New total: ' + this.total());
});
}
increment() {
// .update() uses the current value to calculate the next value
this.quantity.update(q => q + 1);
}
updatePrice(newPrice: number) {
// .set() replaces the value entirely
this.price.set(newPrice);
}
}Note that signals are read by calling them as functions, both in the template ({{ total() }}) and in TypeScript (this.total()).
Integration with RxJS
Signals are not a replacement for RxJS when dealing with complex asynchronous streams (like WebSockets or debounced search inputs). Use the @angular/core/rxjs-interop package to bridge the two:
- toSignal(observable$): Converts an Observable into a read-only Signal. This is useful for consuming data from a service in a template without the
| asyncpipe. - toObservable(signal): Converts a Signal into an Observable, allowing you to apply RxJS operators like
switchMapordebounceTime.
Critical Constraints and Common Pitfalls
The Write-in-Read Restriction
Angular prohibits updating a signal inside a computed() function by default, and writing to signals inside an effect() is also restricted unless explicitly allowed. These guards exist to prevent infinite loops, where an update triggers the consumer, which triggers the update again. Attempting a disallowed write produces a runtime error from the framework. Treat this as a design signal: if you feel the need to write inside a derivation, restructure the state instead of working around the guard.
State Synchronization Anti-patterns
Avoid using effect() to copy one signal's value into another to keep them "in sync." This creates unpredictable data flows and makes debugging difficult. If a value depends on other state, model it as a computed() signal rather than a separate writable signal maintained by an effect. Reserve effects for genuine side effects that leave the reactive graph, such as logging or persisting to storage.
Verification and Diagnostics
To verify that your Signal implementation behaves as expected:
- Check memoization: Place a
console.loginside acomputed()callback. Change a signal that is not read by that computation; the log should not fire. - Verify reactivity: Confirm that updating a signal immediately reflects in the template without a manual call to
ChangeDetectorRef.detectChanges(). - Test the boundary: With
ChangeDetectionStrategy.OnPushon the component, update a signal and confirm only the affected bindings refresh. Browser devtools or Angular DevTools can help you observe which components are checked. - Confirm the guard: Temporarily attempt a
.set()inside acomputed()in a development build and confirm the framework raises an error, then remove the test code.
Limitations
Signals handle synchronous, in-component state well but do not replace RxJS for event-stream orchestration, cancellation, or complex asynchronous coordination. The exact error codes and default behaviors around effect writes have evolved across Angular 16 through 18, so verify behavior against the version pinned in your project before relying on specifics.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.