Guide
Using Angular Signals for Predictable Local State
Learn how Angular Signals provide synchronous, fine‑grained reactivity for local component state, eliminating the need for ChangeDetectorRef and Zone.js in many cases.
Published by Tasadduq Burney
26 Mar 2026, 19:08 UTC
2 min22.9K views0

Why use Angular Signals for local state
Signals provide a synchronous, fine‑grained reactivity primitive that eliminates the need for manual ChangeDetectionStrategy.OnPush or RxJS BehaviorSubject when the state lives only inside a component.
Worked example: a counter component
import { Component } from '@angular/core';
import { signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
Count: {{ count() }}
Double: {{ double() }}
+1
`,
standalone: true
})
export class CounterComponent {
// a writable signal holding the current value
count = signal(0);
// a derived signal that recomputes only when its dependencies change
double = computed(() => this.count() * 2);
// a side‑effect that runs after each change of any signal it reads
constructor() {
effect(() => {
console.log(`Count changed to ${this.count()}`);
});
}
// update the signal value
increment() {
this.count.update(v => v + 1);
}
}
The template reads count() and double() directly; Angular tracks those reads and updates the view automatically when the signal changes. No ChangeDetectorRef, no async pipe, and no reliance on Zone.js for this subtree.
Limits and common mistakes
- Do not place asynchronous work inside
computedoreffect. These functions must be synchronous; for HTTP calls or timers keep the logic in a service or usetoSignalon an Observable outside the effect. - Mutating objects or arrays inside a signal does not trigger updates. Signals compare by reference, so
this.items.update(arr => arr.push(item))leaves the reference unchanged. Replace the whole value:this.items.update(arr => [...arr, item]). - Reading a signal outside an injection context throws. Use
toSignalonly in a constructor, field initializer, or after callinginject(). Provide a fallback with{ initialValue: [] }when the source may emit no value. - Zone.js is still active by default. Signal‑based change detection skips zone checks only when every component in the subtree uses signals and no zone‑dependent APIs (
setTimeout,Promise,EventEmitter) are present. For full zoneless operation addprovideExperimentalZonelessChangeDetection()toapp.config.ts(Angular 18+). - Effects run after every change by default. If you need to write another signal from within an effect, enable
{ allowSignalWrites: true }and guard against infinite loops.
How to verify the behavior
- Create a new Angular 18+ project with
ng new signals-demo --standalone --ssr=false. - Add the
CounterComponentabove to the app. - Serve the application (
ng serve) and click the button; observe the count and double values update in the template and the log appear in the console. - Open Chrome DevTools → Console and confirm that no
zone.jsstack traces appear when the button is clicked (if you have enabled zoneless change detection). - For unit testing, use
fixture.componentInstance.count.set(5)and thenexpect(fixture.componentInstance.double()).toBe(10); the effect will run after afakeAsync tickor afterfixture.detectChanges().
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.