The Fundamentals of Angular Signals

Angular's Signals introduce a fresh reactive primitive that streamlines state management considerably. The compact and approachable API makes it an attractive entry point for developers new to reactive paradigms. A wide range of everyday scenarios can now be handled with surprisingly little code.

For sophisticated cases—such as stream handling, request cancellation, and intricate data pipelines—RxJS still stands as the robust choice. Fortunately, the two coexist harmoniously: Signals and RxJS interoperate cleanly. This compatibility means developers can select the appropriate level of abstraction for each situation without friction.

Additionally, Signals complement Angular's OnPush change detection and are explicitly crafted to align with the upcoming zone-less change detection strategy. They may well underpin future, more granular change detection approaches.

This multi-part series examines the effective use of Angular Signals, ranging from introductory concepts to advanced techniques. This initial installment focuses on the essential primitives:

signal, computed, effect, and untracked.

📂 Source Code (refer to the branches signal and signal-rxjs-interop)

Working with Signals

When integrating Signals with data binding, the properties you wish to display are declared as signals:

@Component([…])
export class FlightSearchComponent {

  private flightService = inject(FlightService);

  from = signal('Hamburg');
  to = signal('Graz');
  flights = signal<Flight[]>([]);

  […]

}

Keep in mind that a Signal always carries a value. Consequently, you must supply a default value to the signal function. If the type isn't inferable from that default, an explicit type parameter can be supplied, as seen in the example.

To retrieve a signal's value, you invoke the getter—technically, the signal is called as if it were a function:

async search(): Promise<void> {
  if (!this.from() || !this.to()) {
    return;
  }
  const flights = await this.flightService.findAsPromise(this.from(), this.to());
  this.flights.set(flights);
}

For updating the value, the signal exposes an explicit setter through its set method. In the demonstrated snippet, the setter is used to store the fetched flights. The getter likewise serves data binding within the template:

<div *ngIf="flights().length > 0">
  {{flights().length}} flights found!
</div>

<div class="row">
  <div *ngFor="let f of flights()">
    <flight-card [item]="f" />
  </div>
</div>

In the past, calling methods directly in templates was discouraged due to potential performance implications. However, this concern applies less to straightforward operations like getters. Moreover, the template registers as a consumer here, meaning it can be properly notified of any updates.

Angular's two-way bindings now offer direct support for Signals:

<form #form="ngForm">
  <div class="form-group">
    <label>From:</label>
    <input [(ngModel)]="from" name="from" class="form-control">
  </div>

  <div class="form-group">
    <label>To:</label>
    <input [(ngModel)]="to" name="to" class="form-control">
  </div>

  <div class="form-group">
    <button class="btn btn-default" (click)="search()">Search</button>
    <button class="btn btn-default" (click)="delay()">Delay</button>
  </div>
</form>

The Angular team plans to migrate forms handling over to Signals in a future release.

Modifying Signal Values

Beyond the setter illustrated above, Signals offer an update method that projects the existing value into a new one:

this.flights.update(f => {
  const flight = f[0];
  const date = addMinutes(flight.date, 15);
  const updated = {...flight, date};

  return [
    updated,
    ...f.slice(1)
  ];
});

Immutability of Signal Values

By design, a Signal's value is expected to remain immutable. So, adjusting only the flight date in the prior section wouldn't be adequate. Instead, you must clone the relevant portions to generate a fresh object reference.

Through this reference comparison, Angular's OnPush change detection can precisely determine which parts within a Signal-managed object have altered. In the prior example, both the Array and the initial flight received new object references. The remaining flights were unmodified and merely carried over via slice, thus preserving their existing references.

Computed Values, Side Effects, and Dependencies

Certain values are inherently derived from others. Angular provides computed signals for precisely this purpose:

flightRoute = computed(() => this.from() + ' to ' + this.to());

Such signals are read-only and simultaneously function as both a consumer and a producer. As a consumer, it reads the values of dependent signals—here from and to—and receives notifications about any modifications. As a producer, it emits the derived result.

For programmatic consumption of signals, the effect function comes into play:

constructor() {
    effect(() => {
        console.log('from:', this.from());
        console.log('route:', this.flightRoute());
    });
}

When invoked, the effect function executes the provided lambda and enrolls itself as a consumer of every signal referenced within. Should any of those signals change, the effect will be re-executed accordingly.

A key point to remember: signals are typically consumed via data binding. Nevertheless, certain situations require a function or method invocation to surface a signal's value to the user—such as writing to a log or showing a toast notification. You can find a deeper discussion on the rationale behind effects in this dedicated article.

The Requirement for Injection Context

Several Signal-related APIs, including effect, are restricted to an injection context. This restricts usage to locations where inject is valid—within the constructor, as initializers for class fields, and inside provider factories. Alternatively, the runInInjectionContext helper allows you to execute arbitrary code within an established injection context.

Consequently, setting up an effect in the constructor, as demonstrated earlier, is required. Attempting to place it in ngOnInit or another lifecycle method will result in a failure:

ngOnInit(): void {
    // Effects are not allowed here.
    // Hence, this will fail:
    effect(() => {
        console.log('route:', this.flightRoute());
    });
}

In such a case, you encounter an error resembling this:

ERROR Error: NG0203: effect() can only be used within an injection context such as a constructor, a factory function,

This limitation arises because effects internally call inject to retrieve the current DestroyRef. This service, available since Angular 16, provides information about the lifespan of the surrounding building block, such as a component or service. The effect leverages the DestroyRef to detach itself just before that building block is disposed of.

Therefore, the customary practice is to establish effects inside the constructor, as demonstrated above. In cases where you truly need to establish an effect elsewhere, the runInInjectionContext function can assist—but it expects a reference to an Injector:

injector = inject(Injector);

ngOnInit(): void {
  runInInjectionContext(this.injector, () => {
    effect(() => {
      console.log('route:', this.flightRoute());
    });
  });
}

Ensuring Glitch-Free Updates

When a signal receives multiple consecutive updates, or when several signals shift in quick succession, intermediate states can surface. Suppose we alter the search criteria from Hamburg - Graz to London - Paris:

setTimeout(() => {
  this.from.set('London');
  this.to.set('Paris');
}, 2000);

Immediately after setting from to London, one could briefly encounter London - Graz. Angular's Signals implementation, like many others, safeguards against such glitches. The Angular team's readme, which also details the underlying push/pull algorithm, refers to this reassuring property as "glitch-free."

Signals and Change Detection

Much like an Observable connected to a template via the async pipe, a bound Signal will trigger change detection. This behavior holds even under the more performant OnPush strategy:

@Component({
  [...]
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlightSearchComponent { [...] } 

[...]

@Component({
  [...]
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlightCardComponent { [...] } 

However, to enable Angular under OnPush to identify which child components require attention, adhering to immutable data patterns—as discussed earlier—is essential.

RxJS Interoperability

At first glance, signals bear a strong resemblance to RxJS Observables, a staple of Angular for years. Yet signals are intentionally more straightforward.

When the full power of RxJS and its operator set becomes necessary, a conversion path is available. The @angular/core/rxjs-interop module exports two utility functions: toObservable, which transforms a Signal into an Observable, and toSignal, which performs the reverse. These bridging functions enable you to blend the simplicity of signals with the depth of RxJS.

The listing below extends the earlier example into a debounced type-ahead, demonstrating both utilities:

@Component([...])
export class FlightSearchComponent {
  private flightService = inject(FlightService);

  from = signal('Hamburg');
  to = signal('Graz');
  basket = signal<Record<number, boolean>>({ 1: true });
  flightRoute = computed(() => this.from() + ' to ' + this.to());

  from$ = toObservable(this.from);
  to$ = toObservable(this.to);

  flights$ = combineLatest({ from: this.from$, to: this.to$ }).pipe(
    filter(c => c.from.length >= 3 && c.to.length >= 3),
    debounceTime(300),
    switchMap(c => this.flightService.find(c.from, c.to))
  );

  flights = toSignal(this.flights$, {
    initialValue: []
  });
}

Here, the signals from and to are converted into the observables from$ and to$, then merged via combineLatest. Any change in these values passes through filtering and debouncing prior to switchMap initiating the backend call. A notable benefit of flattening operators such as switchMap is their built-in guarantees around asynchronicity, which help circumvent race conditions.

The initialValue parameter for toSignal is mandatory because signals always need a starting value. Conversely, observables might never emit. If you're confident that the observable has a synchronous initial emission—perhaps it's a BehaviorSubject or you use the startsWith operator—the requireSync option can be set to true:

flights = toSignal(this.flights, { 
    requireSync: true
});

If neither initialValue nor requireSync is specified, the resulting Signal's type incorporates undefined, permitting an initial value of undefined. In our case, the signal type would become Signal<Flight[] | undefined> rather than Signal<Flight[]>. Your application code would then need to check for undefined explicitly.

Summary

Signals bring a lighter weight approach to reactivity within Angular, simplifying routine tasks with ease. For more demanding use cases, a first-class interop layer provides a seamless bridge over to RxJS.

The Angular team remains consistent with its philosophy: signals are exposed explicitly rather than concealed within proxies or internal structures. This transparency ensures developers always know the data structure they are manipulating. Furthermore, signals are optional—existing code does not require migration, and mixing traditional change detection with signal-based techniques remains fully viable.

Diving Deeper into Modern Angular?

Our complimentary eBook covers everything you need to know about Standalone Components:

  • The conceptual foundation behind Standalone Components
  • Migration paths and compatibility with existing implementations
  • Standalone Components in relation to the router and lazy loading
  • Standalone Components and Web Components
  • Standalone Components integrated with DI and NGRX

The eBook is available at:

free

Feel free to download it right here!