AngularJSSignals

Signals

Signals are Angular's modern reactive primitive, introduced as stable in Angular 17. A signal is a wrapper around a value that notifies consumers whenever the value changes. Unlike RxJS observables, signals are synchronous, always have a current value, and require zero subscriptions or cleanup.

Signals fundamentally change how Angular handles reactivity and change detection — paving the way for a simpler, more performant Angular.

What Is a Signal?

Think of a signal as a "smart variable" — it holds a value and keeps track of who is reading it. When the value changes, all readers are automatically notified and updated.

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

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

// Read the value by calling it like a function
console.log(count()); // 0

// Update the value
count.set(5);
console.log(count()); // 5

// Update based on previous value
count.update(current => current + 1);
console.log(count()); // 6
Signals in Components

Signals are most commonly used inside components to manage local reactive state:

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

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <h2>Count: {{ count() }}</h2>
    <p>Double: {{ double() }}</p>
    <p>Status: {{ status() }}</p>

    <button (click)="increment()">+</button>
    <button (click)="decrement()">-</button>
    <button (click)="reset()">Reset</button>
  `,
})
export class CounterComponent {
  // Writable signal
  count = signal(0);

  // Computed signals (derived, read-only)
  double = computed(() => this.count() * 2);
  status = computed(() => this.count() > 0 ? 'positive' : this.count() < 0 ? 'negative' : 'zero');

  increment() { this.count.update(n => n + 1); }
  decrement() { this.count.update(n => n - 1); }
  reset()     { this.count.set(0); }
}
Note
In templates, signals are read by calling them: count() — not count. Angular's template compiler recognizes signal calls and automatically subscribes to change notifications.
The Three Signal APIs

API

Description

Writable?

signal(initialValue)

Creates a writable signal

Yes — .set(), .update(), .mutate()

computed(() => ...)

Creates a derived read-only signal

No — recalculates automatically

effect(() => ...)

Runs a side-effect when signals change

No — runs but returns void

Writable Signal Methods

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

const name = signal('Alice');
const scores = signal<number[]>([10, 20, 30]);

// .set() — replace the value entirely
name.set('Bob');

// .update() — compute new value from current value
name.update(current => current.toUpperCase()); // 'BOB'

// .update() with objects
const user = signal({ name: 'Alice', age: 25 });
user.update(u => ({ ...u, age: u.age + 1 })); // { name: 'Alice', age: 26 }

// Arrays — always create a new reference for immutable updates
scores.update(s => [...s, 40]); // [10, 20, 30, 40]
scores.update(s => s.filter(n => n > 15)); // [20, 30, 40]
Warning
Signals use reference equality to detect changes. When working with objects or arrays, always return a new reference from update(). Do not mutate in place — Angular will not detect the change.
Read-Only Signals

Services often expose signals as read-only to prevent external mutation. Use asReadonly():

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

@Injectable({ providedIn: 'root' })
export class AuthService {
  // Private writable signal
  private _user = signal<User | null>(null);
  private _isLoading = signal(false);

  // Public read-only signals
  readonly user = this._user.asReadonly();
  readonly isLoading = this._isLoading.asReadonly();
  readonly isLoggedIn = computed(() => this._user() !== null);

  async login(email: string, password: string): Promise<void> {
    this._isLoading.set(true);
    try {
      const user = await this.authApi.login(email, password);
      this._user.set(user);
    } finally {
      this._isLoading.set(false);
    }
  }

  logout(): void {
    this._user.set(null);
  }
}
Signals vs RxJS Observables

Aspect

Signals

Observables

Always has a value

Yes — call it to get current value

No — must subscribe and wait

Synchronous reads

Yes

No — async by nature

Cleanup required

No

Yes — must unsubscribe

Glitch-free

Yes — consistent state

Possible diamond problem

Lazy evaluation

Computed only recalculates if consumed

Cold observables are lazy

Async operations

Use toSignal() wrapper

Native — built for async

Rich operators

Limited (computed, effect)

Vast (map, filter, switchMap...)

Template syntax

count()

stream$ | async

Tip
Signals and Observables are complementary, not competing. Use signals for synchronous state management and UI reactivity. Use Observables for async operations like HTTP, WebSockets, and complex event streams.
Converting Between Signals and Observables

Angular provides utilities in @angular/core/rxjs-interop to bridge signals and observables:

TS
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { inject, Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    @if (users()) {
      @for (user of users(); track user.id) {
        <div>{{ user.name }}</div>
      }
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class UsersComponent {
  private http = inject(HttpClient);

  // Convert Observable to Signal — no async pipe or subscribe() needed
  users = toSignal(this.http.get<User[]>('/api/users'), {
    initialValue: null,
  });
}

TS
import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';
import { debounceTime, switchMap } from 'rxjs';

// Component with search using signal + Observable operators
@Component({ selector: 'app-search' })
export class SearchComponent {
  searchTerm = signal('');

  // Convert signal to Observable to use RxJS operators
  results = toSignal(
    toObservable(this.searchTerm).pipe(
      debounceTime(300),
      switchMap(term => this.http.get<Result[]>(`/api/search?q=${term}`))
    ),
    { initialValue: [] as Result[] }
  );
}
Signal-Based Inputs and Outputs (Angular 17.1+)

Angular 17.1 introduced signal-based input() and output() to replace the decorator-based @Input() and @Output():

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

@Component({
  selector: 'app-product-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ product().name }}</h3>
      <p>{{ product().price }}</p>
      <button (click)="onAddToCart()">Add to Cart</button>
    </div>
  `,
})
export class ProductCardComponent {
  // Signal input — read-only signal in the template
  product = input.required<Product>();

  // Optional input with default
  highlight = input(false);

  // Output — emits events
  addToCart = output<Product>();

  onAddToCart() {
    this.addToCart.emit(this.product());
  }
}

// Two-way binding with model()
@Component({ selector: 'app-toggle' })
export class ToggleComponent {
  checked = model(false); // both readable and writable from outside

  toggle() {
    this.checked.update(v => !v);
  }
}
Practical Example: Shopping Cart with Signals

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

export interface CartItem {
  productId: number;
  name: string;
  price: number;
  quantity: number;
}

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

  readonly items = this._items.asReadonly();

  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0)
  );

  readonly total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  readonly isEmpty = computed(() => this._items().length === 0);

  addItem(product: { id: number; name: string; price: number }): void {
    this._items.update(items => {
      const existing = items.find(i => i.productId === product.id);
      if (existing) {
        return items.map(i =>
          i.productId === product.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...items, { productId: product.id, name: product.name, price: product.price, quantity: 1 }];
    });
  }

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

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

TS
@Component({
  selector: 'app-cart-icon',
  standalone: true,
  template: `
    <button class="cart-btn">
      Cart ({{ cart.itemCount() }})
      @if (!cart.isEmpty()) {
        <span class="total">{{ cart.total() | currency }}</span>
      }
    </button>
  `,
})
export class CartIconComponent {
  protected cart = inject(CartService);
}
Summary
  • Signals are synchronous reactive containers — call them to read, use .set() or .update() to write

  • computed() creates derived read-only signals that recalculate automatically

  • effect() runs side effects when signal dependencies change

  • Use asReadonly() to expose signals publicly without allowing external mutation

  • Convert between Observables and signals with toSignal() and toObservable()

  • Signal inputs (input()) and model() replace @Input() and @Output() in modern Angular

  • Signals enable fine-grained change detection — only components reading a changed signal re-render