Fix: Angular Signals Not Updating — computed() and effect() Not Triggering
Quick Answer
How to fix Angular Signals not updating — signal mutations, computed dependency tracking, effect() cleanup, toSignal() with Observables, and migrating from zone-based change detection.
The Problem
An Angular Signal update doesn’t re-render the component:
count = signal(0);
increment() {
this.count.set(this.count() + 1); // Signal updated...
}
// ...but the template still shows the old valueOr a computed() signal doesn’t recalculate when its dependencies change:
items = signal<string[]>([]);
itemCount = computed(() => this.items().length);
addItem(item: string) {
this.items().push(item); // Mutates the array — computed doesn't update
}Or effect() runs once and then stops triggering:
effect(() => {
console.log('Count changed:', this.count());
});
// Logs once on creation, never again when count changesOr toSignal() from an Observable returns undefined on first access:
data = toSignal(this.http.get<User[]>('/api/users'));
// Template shows nothing — data() is undefined initiallyWhy This Happens
Angular Signals use a push-based reactive system. A Signal only notifies dependents when its reference changes — not when the contained value is mutated in-place. Key behaviors:
- Mutations don’t trigger updates — calling
signal().push()orsignal().someProperty = newValuemutates the internal value without changing the Signal’s reference. Useset()orupdate()to replace the value. computed()tracks access, not assignment — acomputed()only re-evaluates when a Signal it read during its last execution changes. If a dependency is only conditionally read, it may not be tracked.effect()requires reactive context —effect()must be called in an injection context (constructor or withinrunInInjectionContext). It re-runs whenever a Signal it reads changes, but only within its active lifetime.toSignal()starts asundefined— the initial value oftoSignal()isundefineduntil the Observable emits. Use theinitialValueoption to avoid this.- Zone.js and Signals coexist — in zone-based Angular apps, Signals work alongside zone change detection. But
signal.set()in asetTimeoutor outside Angular’s zone may not trigger view updates in hybrid setups.
Fix 1: Replace Mutations with Immutable Updates
Signals only re-notify when the value reference changes. Mutating arrays or objects in-place won’t trigger updates:
// WRONG — mutating the array in-place
items = signal<string[]>(['one', 'two']);
addItem(item: string) {
this.items().push(item); // Mutates — computed() and template don't update
}
removeItem(index: number) {
this.items().splice(index, 1); // Mutates — same problem
}
// CORRECT — replace the array reference using update()
items = signal<string[]>(['one', 'two']);
addItem(item: string) {
this.items.update(current => [...current, item]); // New array reference
}
removeItem(index: number) {
this.items.update(current => current.filter((_, i) => i !== index));
}
// Or with set() — replace the entire value
addItem(item: string) {
this.items.set([...this.items(), item]);
}Objects — don’t mutate properties, replace the object:
user = signal<User>({ name: 'Alice', age: 30 });
// WRONG — mutating a property
updateName(name: string) {
this.user().name = name; // Mutation — Signal doesn't fire
}
// CORRECT — replace with new object
updateName(name: string) {
this.user.update(u => ({ ...u, name }));
}
// Or update a nested value
updateAddress(city: string) {
this.user.update(u => ({
...u,
address: { ...u.address, city }
}));
}Fix 2: Fix computed() Not Updating
computed() tracks the Signals it reads during its last execution. If a dependency is inside a conditional, it may not be tracked:
// POTENTIAL ISSUE — conditional dependency tracking
showDetails = signal(false);
userDetails = signal<UserDetails | null>(null);
// computed() reads userDetails only when showDetails() is true
displayText = computed(() => {
if (this.showDetails()) {
return this.userDetails()?.name ?? 'No name'; // Only tracked when showDetails = true
}
return 'Details hidden';
});
// If showDetails is initially false, computed() doesn't track userDetails
// When userDetails changes while showDetails = false, displayText doesn't update
// When showDetails becomes true, displayText DOES recalculate and pick up the new value
// This is actually correct behavior — but can be surprisingForce all dependencies to be tracked:
// Always read both signals so both are always tracked
displayText = computed(() => {
const show = this.showDetails();
const details = this.userDetails(); // Always read — always tracked
return show ? (details?.name ?? 'No name') : 'Details hidden';
});computed() is lazy and cached — it only re-evaluates when read after a dependency changes. Accessing it in a component template automatically reads it when Angular renders:
// In template — Angular reads this on each render check
{{ displayText() }}
// In component code — reads the current (possibly cached) value
console.log(this.displayText());computed() must be pure — avoid side effects inside computed():
// WRONG — side effect in computed
itemCount = computed(() => {
const count = this.items().length;
this.analytics.track('items-counted', count); // Side effect — don't do this
return count;
});
// CORRECT — side effects belong in effect()
itemCount = computed(() => this.items().length);
constructor() {
effect(() => {
this.analytics.track('items-counted', this.itemCount());
});
}Fix 3: Fix effect() Not Running or Running Incorrectly
effect() must be called in an injection context. After the initial execution, it re-runs when any Signal it read changes:
import { Component, signal, effect, inject, DestroyRef } from '@angular/core';
@Component({ ... })
export class CounterComponent {
count = signal(0);
constructor() {
// CORRECT — effect() called in constructor (injection context)
effect(() => {
console.log('Count is now:', this.count());
// This runs on creation AND whenever count changes
});
}
// WRONG — effect() called outside injection context
setupEffect() {
effect(() => { // Error: effect() must be called in injection context
console.log(this.count());
});
}
}Call effect() outside the constructor using runInInjectionContext:
import { runInInjectionContext, EnvironmentInjector } from '@angular/core';
@Component({ ... })
export class MyComponent {
private injector = inject(EnvironmentInjector);
setupLaterEffect() {
runInInjectionContext(this.injector, () => {
effect(() => {
console.log('Value:', this.count());
});
});
}
}Cleanup in effect() — avoid stale subscriptions:
effect((onCleanup) => {
const subscription = this.websocketService.messages$.subscribe(msg => {
this.messages.update(msgs => [...msgs, msg]);
});
onCleanup(() => {
subscription.unsubscribe(); // Clean up when effect re-runs or component destroys
});
});allowSignalWrites — mutating Signals inside effects:
// By default, writing to a signal inside an effect throws
// (to prevent infinite loops)
effect(() => {
const count = this.count();
this.doubled.set(count * 2); // Error: cannot write signals in effect by default
});
// CORRECT — use computed() for derived values instead of effect()
doubled = computed(() => this.count() * 2);
// Or if you truly need to write: pass allowSignalWrites option
effect(() => {
const count = this.count();
this.doubled.set(count * 2);
}, { allowSignalWrites: true });Fix 4: Fix toSignal() Initial Value
toSignal() wraps an Observable as a Signal. The Signal starts as undefined until the Observable emits:
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({ ... })
export class UserListComponent {
private http = inject(HttpClient);
// PROBLEM — data() is undefined until the HTTP request completes
data = toSignal(this.http.get<User[]>('/api/users'));
// Template: {{ data()?.length }} — shows nothing initially
// CORRECT — provide an initialValue
data = toSignal(this.http.get<User[]>('/api/users'), {
initialValue: [] as User[] // Start with an empty array
});
// Template: {{ data().length }} — shows 0 initially, then the count
// Or use requireSync for synchronous Observables (BehaviorSubject, etc.)
count = toSignal(this.countSubject$, { requireSync: true });
// requireSync: true throws if the Observable doesn't emit synchronously
}Convert a Signal back to an Observable:
import { toObservable } from '@angular/core/rxjs-interop';
count = signal(0);
count$ = toObservable(this.count); // Observable that emits when count changes
// Use in template with async pipe (legacy)
{{ count$ | async }}
// Or use Signal directly in template (preferred in Angular 17+)
{{ count() }}Fix 5: Use Signals with OnPush Change Detection
Signals work best with ChangeDetectionStrategy.OnPush. Angular automatically marks components dirty when a Signal used in the template changes:
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush, // Use OnPush with Signals
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(n => n + 1);
// Angular automatically detects the signal change and re-renders
// No need for ChangeDetectorRef.markForCheck()
}
}Mixing zone-based inputs with Signals:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserComponent {
// Angular 17.1+ — input() signal (replaces @Input decorator)
userId = input.required<number>();
// Derived computed signal
userLabel = computed(() => `User #${this.userId()}`);
}Signal-based @Input with input() (Angular 17.1+):
// Angular 17.1+
import { Component, input, output, computed } from '@angular/core';
@Component({ ... })
export class ProductCardComponent {
// Replaces @Input() — product is a Signal
product = input.required<Product>();
// Replaces @Output() — output() returns an OutputEmitterRef
addToCart = output<Product>();
// Derived from the input Signal
discountedPrice = computed(() =>
this.product().price * (1 - this.product().discountRate)
);
onAddToCart() {
this.addToCart.emit(this.product());
}
}Fix 6: Debug Signal Updates
Angular DevTools (version 17+) shows Signal values. For runtime debugging:
import { signal, computed, effect } from '@angular/core';
// Add temporary effect to log all reads
count = signal(0);
constructor() {
// Log whenever count changes
effect(() => {
console.log('[DEBUG] count signal:', this.count());
// If this doesn't log after count.set(), the effect isn't tracking count
});
// Check computed value
effect(() => {
console.log('[DEBUG] itemCount computed:', this.itemCount());
});
}
// Manually read signal values
inspect() {
console.log('count:', this.count());
console.log('itemCount:', this.itemCount());
}Verify signal updates are flowing:
increment() {
const before = this.count();
this.count.update(n => n + 1);
const after = this.count();
console.log(`count: ${before} → ${after}`); // Should show the new value
// If after === before, update() is not working as expected
}Fix 7: Migrate from RxJS Observables to Signals
A common pattern when migrating existing components:
// BEFORE — RxJS-based component
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
user$: Observable<User>;
userName = '';
ngOnInit() {
this.user$.pipe(takeUntil(this.destroy$)).subscribe(user => {
this.userName = user.name;
this.cdr.markForCheck();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
// AFTER — Signal-based component
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserComponent {
// toSignal handles subscription lifecycle automatically
private user = toSignal(this.userService.user$, { initialValue: null });
// Derived signal — no subscription management
userName = computed(() => this.user()?.name ?? '');
// Template: {{ userName() }} — automatically updates
}Still Not Working?
Zoneless Angular (experimental) — Angular 18 introduced provideExperimentalZonelessChangeDetection(). In zoneless mode, only Signal-based changes and markForCheck() trigger updates. Ensure all data flow goes through Signals.
@Input setter with Signals — if you’re using a traditional @Input() setter and updating a Signal inside it, the Signal update is synchronous and should trigger re-renders:
private _userId = signal(0);
@Input() set userId(value: number) {
this._userId.set(value); // Correctly updates the Signal
}ngZone.runOutsideAngular — code running outside Angular’s zone doesn’t trigger change detection. If you update a Signal from outside the zone, wrap it with ngZone.run():
this.ngZone.runOutsideAngular(() => {
// Long-running work here — won't trigger change detection
heavyComputation().then(result => {
this.ngZone.run(() => {
this.result.set(result); // Back inside zone — triggers update
});
});
});For related Angular issues, see Fix: Angular Change Detection Not Triggering and Fix: Angular RxJS Memory Leak.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular Pipe Not Working — Custom Pipe Not Transforming or async Pipe Not Rendering
How to fix Angular pipe issues — declaring pipes in modules, standalone pipe imports, pure vs impure pipes, async pipe with observables, pipe chaining, and custom pipe debugging.
Fix: Angular HTTP Interceptor Not Working — Requests Not Intercepted
How to fix Angular HTTP interceptors not triggering — provideHttpClient setup, functional interceptors, order of interceptors, excluding specific URLs, and error handling.
Fix: Angular Standalone Component Error — Component is Not a Known Element
How to fix Angular standalone component errors — imports array, NgModule migration, RouterModule vs RouterLink, CommonModule replacement, and mixing standalone with module-based components.