Signals in Angular: from basics to advanced patterns

1. Motivation and Mental Model

Angular's classic reactivity relied on Zone.js patching browser APIs to trigger ApplicationRef.tick() after any async operation. This works, but carries overhead: every macro-task potentially triggers a full component-tree dirty check regardless of what actually changed.

Signals are a fine-grained reactive primitive. A signal wraps a value and tracks which consumers (computed values, effects, template expressions) have read it. When the value changes, only those consumers re-evaluate. The runtime never needs to speculate about what might have changed.

The model is a directed acyclic graph of producers and consumers:

Dependencies are tracked automatically by recording signal reads that occur during a reactive context (computed derivation, effect execution, template rendering). No decorators, no subscription management.

2. Core Primitives

2.1 Writable Signal

import { signal } from '@angular/core';

const count = signal(0);

// Read — invoke as a function
console.log(count()); // 0

// Replace the value entirely
count.set(1);

// Derive the next value from the current one
count.update(v => v + 1);

// Mutate an object/array in-place and notify consumers
const items = signal<string[]>([]);
items.mutate(arr => arr.push('new item')); // Angular 17–18
Warning mutate() was removed in Angular 19. Use update(arr => [...arr, 'new item']) to return a new reference instead. The immutable update pattern is preferred regardless of Angular version.

2.2 Computed Signal

A computed signal is lazy and memoized. It re-evaluates only when a tracked dependency changes and someone reads the computed value.

import { signal, computed } from '@angular/core';

const firstName = signal('Ada');
const lastName  = signal('Lovelace');

const fullName = computed(() =>
  `${firstName()} ${lastName()}`
);

console.log(fullName()); // 'Ada Lovelace'
firstName.set('Grace');
console.log(fullName()); // 'Grace Lovelace' — recomputed only once

A computed signal is read-only; there is no setter. Attempting to call .set() on it is a compile-time and runtime error.

2.3 Effect

An effect is a consumer that runs a side-effecting callback whenever its signal dependencies change. It must be created inside an injection context (constructor, factory function, or via runInInjectionContext).

import { Component, signal, effect, OnInit } from '@angular/core';

@Component({ /* ... */ })
export class CounterComponent {
  count = signal(0);

  constructor() {
    effect(() => {
      // Runs once immediately, then whenever count() changes
      console.log('count is', this.count());
    });
  }
}

Effects are destroyed automatically when the component is destroyed. The cleanup callback returned from effect() provides an EffectRef with a .destroy() method for early teardown.

const ref = effect(() => { /* ... */ });
ref.destroy(); // stops the effect manually

2.4 Cleanup inside effects

Use onCleanup to run teardown logic before each re-run and on destroy:

effect(onCleanup => {
  const id = setInterval(() => {
    console.log(this.tick());
  }, 1000);

  onCleanup(() => clearInterval(id));
});

3. Reactivity Semantics

3.1 Glitch-free evaluation

Angular's signal runtime uses a push-pull approach. When a writable signal changes, it marks downstream computed signals as dirty (push) but does not immediately evaluate them. Evaluation is deferred until a consumer reads the value (pull). This prevents "glitches" — intermediate states where a computed value reflects only part of an update.

const a = signal(1);
const b = computed(() => a() * 2);
const c = computed(() => a() + b());

a.set(2);
// b and c are marked dirty, not yet computed
console.log(c()); // 6 — a=2, b=4, c=6. Never sees c=3.

3.2 Equality checking

By default, signals use Object.is to compare old and new values. Unchanged values do not propagate. You can supply a custom comparator:

const pos = signal({ x: 0, y: 0 }, {
  equal: (a, b) => a.x === b.x && a.y === b.y
});

pos.set({ x: 0, y: 0 });
// No notification — structurally equal

3.3 Untracked reads

Sometimes you need to read a signal inside a reactive context without recording a dependency. Use untracked():

import { untracked } from '@angular/core';

effect(() => {
  console.log(this.primary()); // tracked dependency

  const meta = untracked(() => this.secondary()); // not tracked
  console.log(meta);
});
Note untracked() is also useful in computed signals to read auxiliary values (e.g., configuration) that should not trigger recomputation when they change.

4. Component Integration

4.1 Template bindings

Templates are reactive consumers. Call the signal as a function — no pipe, no async, no subscription.

@Component({
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  count  = signal(0);
  double = computed(() => this.count() * 2);

  increment() { this.count.update(v => v + 1); }
}

When count changes, Angular re-renders only the component that owns it — not the whole tree.

4.2 Change detection strategy

Signals work with both Default and OnPush change detection. With OnPush, the component is scheduled for re-check when any signal it reads has changed. This is the recommended pairing:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})

5. Signal Inputs and Outputs

Angular 17.1+ ships input(), input.required(), output(), and model() as signal-based alternatives to @Input() / @Output().

5.1 input()

import { Component, input, computed } from '@angular/core';

@Component({ selector: 'app-user-card', /* ... */ })
export class UserCardComponent {
  // Optional with default
  name = input('Anonymous');

  // Required — no default; compile error if parent omits it
  userId = input.required<number>();

  // Transform on ingestion
  score = input(0, { transform: Number });

  // Computed from input — no ngOnChanges needed
  label = computed(() => `User #${this.userId()}: ${this.name()}`);
}

Signal inputs are read-only inside the component. The parent controls the value; the child cannot set it. This makes data flow explicit.

5.2 output()

import { Component, output } from '@angular/core';

@Component({ /* ... */ })
export class SearchComponent {
  searched = output<string>();

  onSubmit(query: string) {
    this.searched.emit(query);
  }
}

output() does not use EventEmitter internally. It emits synchronously and has no Observable overhead. Parent template syntax is identical: (searched)="onSearch($event)".

5.3 model() — two-way binding

model() creates a writable signal that is also exposed as an input/output pair. It replaces the [(ngModel)]-style pattern for component APIs.

@Component({
  selector: 'app-toggle',
  template: `<button (click)="toggle()">{{ checked() ? 'ON' : 'OFF' }}</button>`
})
export class ToggleComponent {
  checked = model(false);

  toggle() {
    this.checked.update(v => !v);
    // Automatically emits 'checkedChange' to the parent
  }
}

// Parent template
// <app-toggle [(checked)]="isActive" />
Tip Use model.required() when the parent must always bind a value. This prevents the signal from starting as undefined and eliminates a class of null-check boilerplate.

6. Interop with RxJS

Angular provides two bridge functions in @angular/core/rxjs-interop.

6.1 toObservable()

Converts a signal to an Observable. The Observable emits whenever the signal's value changes and is subscribed within a reactive context (typically an injection context).

import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class SearchService {
  query = signal('');

  results$ = toObservable(this.query).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(q => this.http.get(`/api/search?q=${q}`))
  );
}

toObservable() schedules emissions via effect() internally, so the first emission is asynchronous (microtask). The signal's current value at subscription time is emitted first.

6.2 toSignal()

Converts an Observable to a read-only signal. Angular subscribes automatically and tears down when the injector is destroyed.

import { toSignal } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class PriceComponent {
  private price$ = inject(PriceService).stream$;

  // initialValue required if the Observable hasn't emitted yet
  price = toSignal(this.price$, { initialValue: 0 });

  // Alternatively, requireSync: true — throws if Observable doesn't
  // emit synchronously (e.g. BehaviorSubject)
  syncPrice = toSignal(this.price$, { requireSync: true });
}
Warning If the Observable errors, the error propagates to the signal and any consumer reading it will throw. Pipe a catchError before toSignal() to handle errors gracefully.

6.3 outputFromObservable() / outputToObservable()

import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop';

export class MyComponent {
  // Wraps an Observable as an Angular output
  clicked = outputFromObservable(this.clicks$);

  // Convert a signal output to an Observable for composition
  searched$ = outputToObservable(this.searched);
}

7. Advanced Patterns

7.1 Signal store (manual)

A lightweight service acting as a signal-based store. No library required.

@Injectable({ providedIn: 'root' })
export class CartStore {
  private _items = signal<CartItem[]>([]);

  // Public read-only surface
  items      = computed(() => this._items());
  total      = computed(() =>
    this._items().reduce((sum, i) => sum + i.price * i.qty, 0)
  );
  itemCount  = computed(() =>
    this._items().reduce((n, i) => n + i.qty, 0)
  );

  addItem(item: CartItem) {
    this._items.update(items => {
      const existing = items.find(i => i.id === item.id);
      if (existing) {
        return items.map(i =>
          i.id === item.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...items, { ...item, qty: 1 }];
    });
  }

  removeItem(id: number) {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  clear() { this._items.set([]); }
}

7.2 @ngrx/signals — SignalStore

NgRx 17+ ships @ngrx/signals which provides a structured store built on Angular signals.

import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';

type CounterState = { count: number; step: number };

export const CounterStore = signalStore(
  withState<CounterState>({ count: 0, step: 1 }),

  withComputed(({ count, step }) => ({
    doubled:   computed(() => count() * 2),
    nextValue: computed(() => count() + step()),
  })),

  withMethods(store => ({
    increment() { patchState(store, s => ({ count: s.count + s.step })); },
    reset()     { patchState(store, { count: 0 }); },
  }))
);

Provide via providers: [CounterStore] and inject normally. Each state slice becomes a signal automatically.

7.3 Derived state with dependent effects

Chain computed signals and use effects to synchronize side effects (analytics, local storage) without tangling them into components:

@Injectable({ providedIn: 'root' })
export class ThemeService {
  preference = signal<'light' | 'dark' | 'system'>('system');
  systemDark = toSignal(
    fromEvent(window.matchMedia('(prefers-color-scheme: dark)'), 'change').pipe(
      map((e: any) => e.matches)
    ),
    { initialValue: window.matchMedia('(prefers-color-scheme: dark)').matches }
  );

  resolved = computed(() => {
    const pref = this.preference();
    return pref === 'system' ? (this.systemDark() ? 'dark' : 'light') : pref;
  });

  constructor() {
    effect(() => {
      document.documentElement.dataset['theme'] = this.resolved();
      localStorage.setItem('theme', this.preference());
    });
  }
}

7.4 Async resource pattern

Signal + effect replaces component-level subscribe/unsubscribe for async data:

@Component({ /* ... */ })
export class UserDetailComponent {
  userId = input.required<number>();

  user     = signal<User | null>(null);
  loading  = signal(false);
  error    = signal<string | null>(null);

  private http = inject(HttpClient);

  constructor() {
    effect(onCleanup => {
      const id = this.userId();
      this.loading.set(true);
      this.error.set(null);

      const sub = this.http.get<User>(`/api/users/${id}`)
        .subscribe({
          next:  u  => { this.user.set(u);    this.loading.set(false); },
          error: e  => { this.error.set(e.message); this.loading.set(false); },
        });

      onCleanup(() => sub.unsubscribe());
    });
  }
}
Tip Angular 19+ ships a first-class resource() primitive that codifies exactly this pattern. If available in your version, prefer it over the manual effect approach above.

7.5 resource() — Angular 19+

import { resource, input } from '@angular/core';

@Component({ /* ... */ })
export class UserDetailComponent {
  userId = input.required<number>();

  userResource = resource({
    request: () => ({ id: this.userId() }),
    loader: async ({ request, abortSignal }) => {
      const resp = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
      if (!resp.ok) throw new Error('fetch failed');
      return resp.json() as Promise<User>;
    }
  });

  // Derived signals
  user    = this.userResource.value;
  loading = this.userResource.isLoading;
  error   = this.userResource.error;
}

The resource automatically aborts in-flight requests when userId changes before the previous request completes.

8. Zoneless Change Detection

Signals enable fully zoneless applications. Zone.js is no longer required — Angular uses signal notifications to schedule rendering.

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection()
    // Remove provideZoneChangeDetection()
  ]
});

In angular.json, also remove the Zone.js import from polyfills:

// angular.json (before)
"polyfills": ["zone.js"]

// angular.json (after)
"polyfills": []
Warning Zoneless mode requires that all change detection triggers go through signals, ChangeDetectorRef.markForCheck(), or AsyncPipe. Third-party libraries still relying on Zone.js may require wrappers or NgZone.run() to trigger detection.

8.1 Manual scheduling

If you need to trigger rendering outside a signal context (e.g., a Web Worker message), use ChangeDetectorRef:

private cdr = inject(ChangeDetectorRef);

worker.onmessage = ({ data }) => {
  this.result.set(data);      // signal handles scheduling
  // or if not using signals:
  this.cdr.markForCheck();
};

9. Common Pitfalls

9.1 Mutations without notification

const items = signal([1, 2, 3]);

// ❌ Mutates in place — consumers see the same reference, no notification
items().push(4);

// ✅ Returns a new array — notification fires
items.update(arr => [...arr, 4]);

9.2 Effects writing to signals they also read

Writing to a signal inside an effect that also reads it creates a cycle. Angular detects this in development mode and throws:

// ❌ Infinite loop in dev, silent in prod (may or may not cycle)
effect(() => {
  if (this.count() > 10) this.count.set(0); // reads and writes count
});

// ✅ Break the cycle with untracked
effect(() => {
  const c = this.count();
  if (c > 10) untracked(() => this.count.set(0));
});

Alternatively, restructure so a computed derives the clamped value and a single set() happens outside the effect.

9.3 Creating effects outside injection context

// ❌ Error: effect() must run in an injection context
export class MyService {
  startTracking() {
    effect(() => console.log(this.data()));
  }
}

// ✅ Use runInInjectionContext if outside constructor
private injector = inject(Injector);

startTracking() {
  runInInjectionContext(this.injector, () => {
    effect(() => console.log(this.data()));
  });
}

9.4 Overusing effects for derived state

// ❌ Unnecessary effect — just derive it
tax = signal(0);
constructor() {
  effect(() => this.tax.set(this.subtotal() * 0.08));
}

// ✅ Computed — lazy, memoized, no write necessary
tax = computed(() => this.subtotal() * 0.08);

10. API Quick-Reference

API Import Type Key characteristics
signal(v) @angular/core WritableSignal<T> Root value container. .set(), .update().
computed(fn) @angular/core Signal<T> Lazy, memoized. Read-only. Tracks own dependencies.
effect(fn) @angular/core EffectRef Side-effect runner. Injection context required. Auto-cleanup.
untracked(fn) @angular/core T Reads signals without recording dependencies.
input(default) @angular/core InputSignal<T> Signal-based @Input. Read-only inside component.
input.required() @angular/core InputSignal<T> Required input. Compile error if omitted by parent.
output() @angular/core OutputEmitterRef<T> Signal-based @Output. Synchronous, no EventEmitter.
model() @angular/core ModelSignal<T> Two-way binding. Writable inside child, reflects to parent.
toSignal(obs$) @angular/core/rxjs-interop Signal<T> Observable → Signal. Auto-subscribes and unsubscribes.
toObservable(sig) @angular/core/rxjs-interop Observable<T> Signal → Observable. Emissions are async (microtask).
resource(opts) @angular/core Resource<T> Angular 19+. Async data loading with abort and status signals.
Concern Old approach Signal approach
Reactive state RxJS BehaviorSubject + async pipe signal() read in template
Derived state RxJS combineLatest + map computed()
Side effects tap/subscribe + lifecycle hooks effect()
Component inputs @Input() + ngOnChanges input() + computed()
Component outputs @Output() EventEmitter output()
Two-way binding @Input + @Output + ChangeEvent model()
Async data subscribe + unsubscribe lifecycle toSignal() or resource()
Change detection Zone.js + Default strategy Signals + OnPush or zoneless