Skip to content

Fix: Angular Signals Not Updating — computed() and effect() Not Triggering

FixDevs ·

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 value

Or 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 changes

Or toSignal() from an Observable returns undefined on first access:

data = toSignal(this.http.get<User[]>('/api/users'));
// Template shows nothing — data() is undefined initially

Why 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() or signal().someProperty = newValue mutates the internal value without changing the Signal’s reference. Use set() or update() to replace the value.
  • computed() tracks access, not assignment — a computed() 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 contexteffect() must be called in an injection context (constructor or within runInInjectionContext). It re-runs whenever a Signal it reads changes, but only within its active lifetime.
  • toSignal() starts as undefined — the initial value of toSignal() is undefined until the Observable emits. Use the initialValue option to avoid this.
  • Zone.js and Signals coexist — 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.

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 surprising

Force 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles