Fix: Angular Signals Not Updating — computed() and effect() Not Triggering
Part of: React & Frontend Errors
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. This distinction is the root cause of nearly every “Signals not updating” report.
When you call signal().push() or signal().someProperty = newValue, you are mutating the internal value without changing the Signal’s reference. The Signal’s equality check sees the same object reference and concludes nothing changed. Use set() or update() to replace the value with a new reference.
computed() tracks the Signals it read during its last execution. If a dependency is only conditionally read, it may not be tracked on runs where the branch is skipped. This is correct behavior, but it surprises teams migrating from zone-based change detection where everything re-evaluated on every cycle. effect() has its own requirement: it must be called inside an injection context (constructor or runInInjectionContext). Outside that context, Angular throws — or the effect silently does nothing.
toSignal() wraps an Observable as a Signal. The Signal starts as undefined until the Observable emits its first value. Templates that don’t guard against the initial undefined render empty content. In zone-based Angular apps, Signals work alongside zone change detection. But signal.set() in a setTimeout or outside Angular’s zone may not trigger view updates in hybrid setups, because the zone scheduler doesn’t know about the Signal change.
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
}Production Incident Patterns
Stale UI after a Signals migration is one of those bugs that passes unit tests and only surfaces when real users interact with the application. The blast radius is typically scoped to the component tree that was migrated, but the impact can be severe when the stale state involves prices, inventory counts, or access controls.
Scenario: zone.js removal causes stale dashboard. A team migrates their admin dashboard from zone-based change detection to Signals. Unit tests pass. E2E tests pass on the happy path. Two weeks after deployment, support tickets report that the “active users” widget shows the same number all day. The root cause: a third-party charting library updates the DOM inside a setTimeout callback, which fires outside the Angular zone. Before the migration, zone.js patched setTimeout and triggered change detection. After the migration, the Signal backing the chart data is set correctly, but the charting library’s internal state is never reconciled because it reads a stale closure, not the Signal value.
Detecting stale Signals in production. E2E test suites are the primary safety net. Add assertions that verify data freshness after interactions, not just that the element exists. For example, after clicking “refresh,” assert the timestamp text changed, not just that the component rendered. Synthetic monitoring (Datadog, Checkly, Playwright-based) should include flows that mutate state and verify the template reflects it.
Recovery playbook. When a component tree shows stale state post-migration, the fastest mitigation is to re-add ChangeDetectorRef.markForCheck() at the Signal update site as a safety fallback. This gives you a working production state while you track down the exact dependency chain that lost reactivity. Once the root cause is identified — usually a mutation instead of a replacement, or a setTimeout outside the zone — remove the markForCheck crutch and apply the proper fix.
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
});
});
});Signal equality function suppressing updates — Signals use Object.is() as the default equality check. If you store objects and create a new reference with identical property values, Object.is() returns false and the Signal fires. But if you provide a custom equal function that compares deeply, legitimate updates may be suppressed. Review any custom equal options on your Signals if updates seem swallowed.
toSignal() with a shareReplay Observable — if the source Observable uses shareReplay and the last emitted value arrives synchronously, toSignal() picks it up immediately. But if the Observable has no replayed value and emits asynchronously, the Signal stays undefined until emission. Ensure the Observable’s replay behavior matches your timing expectations.
For related Angular issues, see Fix: Angular Change Detection Not Triggering, Fix: Angular RxJS Memory Leak, Fix: Angular Expression Changed After It Has Been Checked, and Fix: Angular Standalone Component Error.
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.