Skip to main content
Technology & EngineeringAngular258 lines

Angular Signals

Angular Signals for fine-grained reactivity and efficient change detection

Quick Summary29 lines
You are an expert in Angular Signals for building reactive Angular applications with fine-grained reactivity.

## Key Points

- Logging and analytics
- Syncing with `localStorage`
- Custom DOM manipulation not covered by the template
- Third-party library integration
1. **Prefer `computed` over `effect` for derived state.** Computed signals are lazy, memoized, and declarative. Effects are imperative and harder to reason about.
2. **Expose read-only signals from services.** Use `asReadonly()` to prevent external code from mutating state directly:
3. **Use `input()` and `model()` instead of `@Input()` and `@Output()`.** Signal-based inputs enable better type safety and remove the need for `ngOnChanges`.
4. **Use `toSignal` with an `initialValue`.** Without one, the signal type includes `undefined`, which complicates downstream code.
5. **Keep effects focused.** Each effect should handle a single responsibility. Avoid reading many signals in one effect.
6. **Avoid writing to signals inside `computed`.** Computed callbacks must be pure — no side effects.
- **Reading signals outside a reactive context.** Calling a signal inside `setTimeout` or a promise callback will not track dependencies. Use `effect` or `toObservable` for async scenarios.
- **Creating effects outside an injection context.** `effect()` must be called in a constructor or inside `runInInjectionContext`. Otherwise it throws a runtime error.

## Quick Example

```html
<app-toggle [(checked)]="isEnabled" />
```

```typescript
private _data = signal<Data[]>([]);
   readonly data = this._data.asReadonly();
```
skilldb get angular-skills/Angular SignalsFull skill: 258 lines
Paste into your CLAUDE.md or agent config

Angular Signals — Angular

You are an expert in Angular Signals for building reactive Angular applications with fine-grained reactivity.

Core Philosophy

Angular Signals represent the framework's shift from zone.js-driven change detection to fine-grained reactivity. The core idea is simple: a signal is a value that notifies consumers when it changes. But the implications are profound — signals enable Angular to know exactly which parts of the template need updating, eliminating the need for top-down dirty checking. This makes applications faster by default and removes an entire category of performance debugging (unnecessary change detection cycles).

The signal API is deliberately minimal: signal() creates writable state, computed() derives values, and effect() handles side effects. This three-primitive model maps directly to the reactive programming fundamentals of state, derivation, and side effects. The hierarchy matters: computed should vastly outnumber effect in any codebase. If you find yourself writing many effects that update other signals, you are likely doing derived state computation that belongs in computed. Effects are for the edges of the system — syncing with localStorage, logging, integrating with third-party libraries.

Signal inputs (input()) and model signals (model()) extend the reactive model to component boundaries. They replace @Input() and @Output() with a unified, signal-based API that eliminates ngOnChanges, enables computed derivations from inputs, and provides better type inference. Adopting signal inputs is not just a syntax preference — it fundamentally simplifies how components react to input changes.

Anti-Patterns

  • Using effect() for Derived State — writing effect(() => { this.total.set(this.price() * this.quantity()); }) when computed(() => this.price() * this.quantity()) is simpler, lazy, memoized, and does not require cleanup.

  • Mutating Signal Values In Place — modifying an object inside a signal without creating a new reference, then expecting the UI to update. Signals use referential equality by default, so update(arr => { arr.push(item); return arr; }) does not trigger because the same array reference is returned.

  • Effects Outside Injection Context — calling effect() inside a setTimeout, promise .then(), or standalone function where no injection context exists. Effects must be created in constructors, field initializers, or within runInInjectionContext.

  • Reading Signals in Non-Reactive Contexts — calling a signal inside setTimeout or a promise callback expecting it to track dependencies. Signal reads only create reactive subscriptions inside computed, effect, or Angular's template rendering context.

  • Forgetting untracked in Effects — reading a signal inside an effect that you do not want to track as a dependency. Without untracked(() => sig()), every signal read creates a dependency that re-triggers the effect on change.

Overview

Angular Signals, introduced in Angular 16 and stabilized in Angular 17+, provide a synchronous, fine-grained reactivity primitive that tracks how and where state is used throughout an application. Signals replace many use cases for RxJS-based state management and zone.js-driven change detection with a simpler, more predictable model.

Core Concepts

Writable Signals

A writable signal holds a value and exposes methods to update it:

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

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

// Read the current value by calling the signal
console.log(count()); // 0

// Set a new value
count.set(5);

// Update based on the previous value
count.update(prev => prev + 1);

Computed Signals

Computed signals derive values from other signals. They are lazy and memoized — they only recompute when a dependency changes and only when read:

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

const firstName = signal('Jane');
const lastName = signal('Doe');

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

console.log(fullName()); // "Jane Doe"
firstName.set('John');
console.log(fullName()); // "John Doe"

Effects

Effects run side effects whenever the signals they read change. They execute at least once and then re-execute when any dependency signal changes:

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

const query = signal('');

effect(() => {
  console.log(`Search query changed: ${query()}`);
});

Effects are primarily intended for:

  • Logging and analytics
  • Syncing with localStorage
  • Custom DOM manipulation not covered by the template
  • Third-party library integration

Signal Inputs

Angular 17.1+ introduced signal-based inputs for components:

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

@Component({
  selector: 'app-user-card',
  template: `<h2>{{ name() }}</h2><p>{{ bio() }}</p>`,
})
export class UserCardComponent {
  name = input.required<string>();
  bio = input<string>('No bio provided');
}

Model Signals (Two-Way Binding)

Angular 17.2+ added the model() function for two-way binding:

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

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

Used in a parent:

<app-toggle [(checked)]="isEnabled" />

Implementation Patterns

Signal Store Pattern

A lightweight store built with signals, useful for component or feature-level state:

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

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoStore {
  private readonly _todos = signal<Todo[]>([]);

  readonly todos = this._todos.asReadonly();
  readonly completedCount = computed(() =>
    this._todos().filter(t => t.completed).length
  );
  readonly pendingCount = computed(() =>
    this._todos().filter(t => !t.completed).length
  );

  addTodo(title: string): void {
    this._todos.update(todos => [
      ...todos,
      { id: Date.now(), title, completed: false },
    ]);
  }

  toggleTodo(id: number): void {
    this._todos.update(todos =>
      todos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }

  removeTodo(id: number): void {
    this._todos.update(todos => todos.filter(t => t.id !== id));
  }
}

Converting Between Signals and Observables

Angular provides interop functions in @angular/core/rxjs-interop:

import { signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { switchMap } from 'rxjs';

@Component({ /* ... */ })
export class SearchComponent {
  query = signal('');

  private query$ = toObservable(this.query);

  results = toSignal(
    this.query$.pipe(
      switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`))
    ),
    { initialValue: [] }
  );

  constructor(private http: HttpClient) {}
}

Signal-Based Component with OnPush

Signals work seamlessly with OnPush change detection, and in Angular 18+ with the new zoneless change detection:

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubled() }}</p>
    <button (click)="increment()">+1</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment(): void {
    this.count.update(c => c + 1);
  }
}

Best Practices

  1. Prefer computed over effect for derived state. Computed signals are lazy, memoized, and declarative. Effects are imperative and harder to reason about.

  2. Expose read-only signals from services. Use asReadonly() to prevent external code from mutating state directly:

    private _data = signal<Data[]>([]);
    readonly data = this._data.asReadonly();
    
  3. Use input() and model() instead of @Input() and @Output(). Signal-based inputs enable better type safety and remove the need for ngOnChanges.

  4. Use toSignal with an initialValue. Without one, the signal type includes undefined, which complicates downstream code.

  5. Keep effects focused. Each effect should handle a single responsibility. Avoid reading many signals in one effect.

  6. Avoid writing to signals inside computed. Computed callbacks must be pure — no side effects.

Common Pitfalls

  • Reading signals outside a reactive context. Calling a signal inside setTimeout or a promise callback will not track dependencies. Use effect or toObservable for async scenarios.

  • Creating effects outside an injection context. effect() must be called in a constructor or inside runInInjectionContext. Otherwise it throws a runtime error.

  • Overusing effects for state derivation. Writing to a signal inside an effect to derive state creates unnecessary complexity. Use computed instead.

  • Forgetting untracked in effects. If an effect reads a signal it should not track, wrap the read in untracked(() => sig()) to avoid unwanted re-executions.

  • Mutating objects inside signals. Signals use referential equality by default. Mutating an object in place and calling set() with the same reference will not trigger updates. Always create a new reference.

Install this skill directly: skilldb add angular-skills

Get CLI access →