Docs
/
Angular
Chapter 11

11 — Signals & Reactivity

What are Signals?

Signals (Angular 16+) are a synchronous reactive primitive that notify consumers when their value changes. They are Angular's answer to fine-grained reactivity (like Vue's ref or Solid's signals).

signal(value)  →  computed(fn)  →  effect(fn)
  (writable)       (derived)       (side effect)

Core API

signal() — Writable Signal

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

const count = signal(0);           // Create with initial value

// Read
console.log(count());              // 0

// Write
count.set(5);                      // Set new value
count.update(c => c + 1);         // Functional update (prev → next)

// Read-only view
const readOnly = count.asReadonly();
// readOnly.set(5)  ← ❌ Error — cannot write

computed() — Derived Signal

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

const price    = signal(100);
const quantity = signal(3);
const tax      = signal(0.1);

// Automatically recalculates when ANY dependency changes
const subtotal = computed(() => price() * quantity());
const total    = computed(() => subtotal() * (1 + tax()));

console.log(total());   // 330

price.set(200);
console.log(total());   // 660 — auto-updated

> computed is lazy — it only recalculates when read AND a dependency has changed.

effect() — Side Effects

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

const user = signal<User | null>(null);

// Runs when user signal changes
effect(() => {
  const u = user();
  if (u) {
    console.log(`User changed: ${u.name}`);
    localStorage.setItem('lastUser', JSON.stringify(u));
  }
});

Rules:

  • Effects run at least once (during creation)
  • Effects track which signals they read and re-run when those change
  • Must be created in an injection context (constructor, field initializer, or with inject)
  • Use untracked() to read a signal without tracking it
effect(() => {
  const name = this.name();                     // Tracked — triggers re-run
  const config = untracked(() => this.config()); // NOT tracked — won't trigger
  console.log(name, config);
});

Signals in Components

@Component({
  template: `
    <h1>{{ title() }}</h1>
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>

    <button (click)="increment()">+</button>
    <button (click)="reset()">Reset</button>

    @if (isHigh()) {
      <p class="warning">Count is high!</p>
    }
  `,
})
export class CounterComponent {
  title = signal('My Counter');
  count = signal(0);
  double = computed(() => this.count() * 2);
  isHigh = computed(() => this.count() > 10);

  increment() { this.count.update(c => c + 1); }
  reset()     { this.count.set(0); }
}

> No async pipe needed — signals are synchronous. Just call signal() in the template.


Signal Inputs (Angular 17.1+)

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

@Component({
  selector: 'app-user-card',
  template: `
    <div>
      <h3>{{ name() }}</h3>
      <p>Role: {{ role() }}</p>
      <button (click)="selectUser()">Select</button>
    </div>
  `,
})
export class UserCardComponent {
  // Signal-based inputs
  name = input.required<string>();          // Required
  role = input<string>('user');             // Optional with default
  disabled = input(false);                  // Inferred type

  // Signal-based output
  selected = output<string>();

  // Computed from input signal
  isAdmin = computed(() => this.role() === 'admin');

  selectUser() {
    this.selected.emit(this.name());
  }
}
<app-user-card [name]="'Alice'" [role]="'admin'" (selected)="onSelect($event)" />

input() vs @Input()

input() (signal)@Input() (decorator)
ReturnsInputSignal<T>T
Reactive✅ Auto-tracked❌ Need ngOnChanges
computed()✅ Works directly❌ Not reactive
Requiredinput.required<T>()@Input({ required: true })
Transforminput(0, { transform: numberAttribute })@Input({ transform })

Signal Queries (Angular 17.2+)

import { viewChild, viewChildren, contentChild, contentChildren } from '@angular/core';

@Component({ ... })
export class ParentComponent {
  // Signal-based ViewChild
  chart = viewChild<ChartComponent>('chart');        // Optional
  input = viewChild.required<ElementRef>('myInput');  // Required

  // Signal-based ViewChildren
  items = viewChildren(ItemComponent);                // Signal<readonly ItemComponent[]>

  ngAfterViewInit() {
    // No need for AfterViewInit — computed/effects react automatically
    console.log(this.items().length);
  }

  focusInput() {
    this.input().nativeElement.focus();
  }
}

RxJS ↔ Signals Interop

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

// Observable → Signal
@Component({ ... })
export class UserComponent {
  private userService = inject(UserService);

  // Convert Observable to Signal (auto-subscribes, auto-unsubscribes)
  users = toSignal(this.userService.getAll(), { initialValue: [] });
  // users() → User[]

  // With error handling
  user = toSignal(
    this.userService.getById('123').pipe(catchError(() => of(null))),
  );
}

// Signal → Observable
export class SearchComponent {
  query = signal('');

  // Convert Signal to Observable
  query$ = toObservable(this.query);

  results = toSignal(
    this.query$.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => this.searchService.search(q)),
    ),
    { initialValue: [] },
  );
}

Signal-Based Component Pattern

@Component({
  template: `
    @if (vm(); as vm) {
      <h1>{{ vm.user.name }}</h1>
      <p>Orders: {{ vm.orderCount }}</p>
      @for (order of vm.recentOrders; track order.id) {
        <app-order-card [order]="order" />
      }
    } @else {
      <app-spinner />
    }
  `,
})
export class UserDashboardComponent {
  private userService = inject(UserService);
  private orderService = inject(OrderService);

  userId = input.required<string>();

  private user = toSignal(
    toObservable(this.userId).pipe(
      switchMap(id => this.userService.getById(id)),
    ),
  );

  private orders = toSignal(
    toObservable(this.userId).pipe(
      switchMap(id => this.orderService.getByUser(id)),
    ),
    { initialValue: [] },
  );

  // View model — single computed for the template
  vm = computed(() => {
    const user = this.user();
    if (!user) return null;
    return {
      user,
      orderCount: this.orders().length,
      recentOrders: this.orders().slice(0, 5),
    };
  });
}

Migrating from RxJS to Signals

RxJS PatternSignal Equivalent
BehaviorSubjectsignal()
Observable.pipe(map(...))computed()
subscribe() (side effect)effect()
combineLatest([a$, b$])computed(() => [a(), b()])
async pipeJust call signal() in template
distinctUntilChangedBuilt-in (signals skip equal values)

Key Takeaways

  • Signals are synchronous — no async pipe, no subscription management
  • signal() = state, computed() = derived, effect() = side effects
  • Use input() and output() functions instead of @Input() / @Output() decorators
  • toSignal() bridges observables into the signal world (HTTP, router, forms)
  • computed() is lazy and memoized — only recalculates when dependencies change
  • Expose asReadonly() signals from services to prevent external mutation
  • Signals work with OnPush change detection and future zoneless Angular