Skillfully Using Signals in Angular – Selected Hints for Professional Use

  1. Signals in Angular: The Future of Change Detection
  2. Component Communication with Signals: Inputs, Two-Way Bindings, and Content/ View Queries
  3. Successful with Signals in Angular – 3 Effective Rules for Your Architecture
  4. Skillfully Using Signals in Angular – Selected Hints for Professional Use

The new Signals in Angular are a simple reactive building block. However, as often, the devil is in the details. In this article, I'll give three hints to help you use Signals more straightforwardly.

📂 Source Code

Guiding Theory: Unidirectional Data Flow With Signals

The approach to establishing a unidirectional data flow presented in the last article serves as the guiding theory for the three hints presented here:

Unidirectional data flow with a store

Handlers for UI events delegate to the store. I use the abstract term intention for this, as different stores have completely different implementations for this: In the Redux-based NGRX Store, Actions are dispatched; in the lightweight NGRX Signal Store, however, the component calls a method offered by the store.

The store then executes synchronous or asynchronous tasks. These usually lead to a change in state, which the application transports to the views of the individual components via signals. As part of this data flow, the state can be projected onto View Models using computed, i.e., onto data structures that represent the view of individual use cases on the state.

This approach is based on the fact that Signals are primarily suitable for informing the view synchronously about data and data changes. They are less suitable for asynchronous tasks and for representing events: Firstly, they do not offer an easy way to deal with overlapping asynchronous requests and the resulting race conditions. In addition, they cannot directly represent error states. Secondly, Signals ignore the resulting intermediate states when value changes occur in direct succession. This desired property is called glitch-free.

For example, if a Signal changes from 1 to 2 and immediately afterwards from 2 to 3, the consumer only receives a notification about the 3. This is also beneficial for data binding performance, especially since updating with intermediate results would result in unnecessary tasks.

Hint 1: Signals Harmonize with RxJS

Signals are deliberately kept simple. That's why they offer fewer options than RxJS, which has been established in the Angular world for years. Thanks to the RxJS interop that Angular brings, you can combine the best of both worlds. The following listing demonstrates this:

@Component({
  selector: 'app-desserts',
  standalone: true,
  imports: [DessertCardComponent, FormsModule, JsonPipe],
  templateUrl: './desserts.component.html',
  styleUrl: './desserts.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DessertsComponent {
  #dessertService = inject(DessertService);
  #ratingService = inject(RatingService);
  #toastService = inject(ToastService);

  originalName = signal('');
  englishName = signal('Cake');
  loading = signal(false);

  ratings = signal<DessertIdToRatingMap>({});
  ratedDesserts = computed(() => this.toRated(this.desserts(), this.ratings()));

  originalName$ = toObservable(this.originalName);
  englishName$ = toObservable(this.englishName);

  desserts$ = combineLatest({
    originalName: this.originalName$,
    englishName: this.englishName$,
  }).pipe(
    filter((c) => c.originalName.length >= 3 || c.englishName.length >= 3),
    debounceTime(300),
    tap(() => this.loading.set(true)),
    switchMap((c) =>
      this.#dessertService.find(c).pipe(
        catchError((error) => {
          this.#toastService.show('Error loading desserts!');
          console.error(error);
          return of([]);
        }),
      ),
    ),
    tap(() => this.loading.set(false)),
  );

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

  […]
}

This example converts the Signals from and to into Observables and implements a typeahead based on this. For this, it uses the filter , debounceTime and switchMap operators offered by RxJS. The latter also prevents race conditions resulting from overlapping requests by only respecting the most recent request. All other still running requests are cancelled.

At the end, the resulting Observable is converted to a Signal so that the application can continue with the new Signals API. Of course, for performance reasons, the application should not switch between the two worlds too often.

Unlike in the figure above, no store is used here. Both the intention and the asynchronous action take place in the reactive data flow. If you move the data flow to a Service that shares the loaded data, e.g., with shareReplay, you could already see this service as a simple store. However, like in the discussed figure, the component delegates the execution of an asynchronous task to somewhere else and gets the resulting state via Signals.

RxJS in Stores

RxJS is also often used in stores. For instance, the traditional NGRX store uses RxJS for Actions and Effects. As an alternative to Effects, the NGRX Signal Store offers reactive methods that can be defined with rxMethod:

export const DessertStore = signalStore(
  { providedIn: 'root' },
  withState({
    filter: {
      originalName: '',
      englishName: 'Cake',
    },
    loading: false,
    ratings: {} as DessertIdToRatingMap,
    desserts: [] as Dessert[],
  }),
  […]
  withMethods(
    (
      store,
      dessertService = inject(DessertService),
      toastService = inject(ToastService),
    ) => ({

      […]
      loadDessertsByFilter: rxMethod<DessertFilter>(
        pipe(
          filter(
            (f) => f.originalName.length >= 3 || f.englishName.length >= 3,
          ),
          debounceTime(300),
          tap(() => patchState(store, { loading: true })),
          switchMap((f) =>
            dessertService.find(f).pipe(
              tapResponse({
                next: (desserts) => {
                  patchState(store, { desserts, loading: false });
                },
                error: (error) => {
                  toastService.show('Error loading desserts!');
                  console.error(error);
                  patchState(store, { loading: false });
                },
              }),
            ),
          ),
        ),
      ),
    }),
  ),
  withHooks({
    onInit(store) {
      const filter = store.filter;
      store.loadDessertsByFilter(filter);
    },
  }),
);

This example sets up a reactive loadDessertsByFilter method in the store. Since it is defined with rxMethod<DessertFilter>, it accepts an Observable<DessertFilter>. The values of this Observable pass through the specified pipe. Since rxMethod automatically subscribes this Observable, the application code must receive the result of the data flow using tap or tabResponse. The latter is an operator from the ngrx/operators package combining the functionality of tap, catchError, and finalize.

The consumer of a reactive method can pass a corresponding Observable, but also a Signal or a concrete value. For example, the onInit hook shown passes the filter Signal. This means that all values that the Signal gradually receives pass through the pipe in loadDessertsByFilter. respecting the above-mentioned glitch-free property.

Interestingly, rxMethod can also be used outside of the Signal Store by design. For example, a component could use it to set up a reactive method.

Angular Architecture Workshop (online, interactive, advanced)

Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!

All Details (English Workshop) | All Details (German Workshop)

Hint 2: Avoiding Race Conditions

Overlapping asynchronous operations usually lead to undesirable race conditions. For example, if the user searches for two different desserts in quick succession, both results might be displayed one after the other. One of the two only flashes briefly before the other replaces it. Due to the asynchronous nature, the order of results does not need to match the order of requests.

To prevent this confusing behavior, RxJS provides various flattening operators:

  • switchMap
  • mergeMap
  • concatMap
  • exhaustMap

These operators differ in how they handle overlapping requests. The switchMap already discussed above always only returns the result of the last search query if there are several overlapping ones. Any requests that may already be in progress are canceled when a new request arrives. This behavior is what users intuitively expect when working with search filters.

The mergeMap and concatMap operators perform all queries: the former in parallel and the latter sequentially. The exhaustMap operator ignores further requests while one is running. These capabilities are another reason to use RxJS, as well as the previously discussed RxJS interop and rxMethod.

Another strategy that is often used in addition or as an alternative is a flag that informs whether the application is currently communicating with the backend:

loadRatings(): void {
  patchState(store, { loading: true });

  ratingService.loadExpertRatings().subscribe({
    next: (ratings) => {
      patchState(store, { ratings, loading: false });
    },
    error: (error) => {
      patchState(store, { loading: false });
      toastService.show('Error loading ratings!');
      console.error(error);
    },
  });
},

Depending on the value of this flag, the application could display a loading indicator or disable the respective button. The latter is of course counterproductive with a highly reactive UI or not possible at all if the application does not have an explicit button.

Hint 3: Signals as Triggers

As mentioned at the beginning, Signals are primarily suitable for transporting data into the view. This is shown on the right in the initial figure. For conveying an intention, real events provided by the browser (e.g. UI events) and/or represnted by Observables are the better solution. There are several reasons for this: Firstly, the glitch-free nature of Signals would reduce consecutive changes to the last change.

On the other hand, the consumer must subscribe to the signal in order to be able to react to changes in value. To do this, they need an Effect that triggers the desired action and writes the result obtained into a Signal. Now Effects that write to Signals are not popular and are even penalized by Angular with an exception by default. The Angular team wants to avoid confusing reactive chains, i.e., changes that lead to changes, which in turn lead to further changes.

However, Angular is switching more and more APIs to Signals. An example of this are Signals that can be bound to form fields, or Signals that represent passed values (inputs). In most cases it could be argued that instead of listening for the Signal, you could also use the event that led to the Signal change. In some cases, however, this is quite a detour that bypasses the new signal-based APIs.

As an example of such situations, the following listing shows a component that accepts an ID as an InputSignal. The router bindings a routing parameter to this ID leveraging the relatively new feature withComponentInputBinding. After receiving a new ID, the components needs to load the corresponding record:

@Component({ […] })
export class DessertDetailComponent implements OnChanges {

  store = inject(DessertDetailStore);

  dessert = this.store.dessert;
  loading = this.store.loading;

  id = input.required({
    transform: numberAttribute
  });

  […]
}

The template of this component allows you to browse between the records. This logic is deliberately implemented very simply for this example:

<button [routerLink]="['..', id() + 1]" >
    Next
</button>

When clicking the button Next, the InputSignal id receives a new value. The question now arises as to how to trigger the loading of the respective data record in the event of such a change. The classic approach is probably to use the life cycle hook ngOnChanges:

ngOnChanges(): void {
  const id = this.id();
  this.store.load(id);
}

There's nothing wrong with that for now. However, the planned Signal-based components will no longer offer this lifecycle hook. As a replacement, the RFC about Signal-based Components suggest the use of Effects.

As an alternative, one can use the rxMethod offered by the Signal Store:

constructor() {
  this.store.rxLoad(this.id);
}

It is important to note that the constructor passes the entire Signal and not just its current value. The rxMethod subscribes to this Signal and passes its values to an Observable that is used within the rxMethod.

If you don't want to use the Signal Store, you can instead go with the RxJS interop discussed above and use toObservable to convert the signal into an Observable.

If you don’t have a reactive method at hand, you might be tempted to define an Effect for this task:

constructor() {
  effect(() => {
    this.store.load(this.id());
  });
}

Unfortunately, this leads to an exception:

Error message when using effect

This problem arises because the entire load method that writes a Signal in the store is executed in the reactive context of the Effect. Hence, from Angular's perspective, this is an effect that writes into a Signal. This must be prevented by default for the reasons mentioned above. Also, Angular will trigger the Effect again even if a Signal read within load changes.

Both problems can be prevented by using the untracked function:

constructor() {
  // try to avoid this
  effect(() => {
    const id = this.id();
    untracked(() => {
      this.store.load(id);
    });
  });
}

In this now common pattern, untracked ensures that the reactive context does not spill over to the load method. This means that it can write to Signals and the Effect does not register for Signals read within load. Angular only triggers the effect again when the Signal id changes, since it reads it outside of untracked.

Unfortunately, this code is not particularly easy to read. That's why it makes sense to hide it behind an auxiliary function:

constructor() {
  explicitEffect(this.id, (id) => {
    this.store.load(id);
  });
}

The created auxiliary function explicitEffect accepts a sSignal and subscribes it with an Effect. The Effect triggers the passed lambda expression via untracked:

import { Signal, effect, untracked } from "@angular/core";

export function explicitEffect<T>(source: Signal<T>, action: (value: T) => void) {
    effect(() => {
        const s = source();
        untracked(() => {
            action(s)
        });
    });
}

Interestingly, this combination of effect and untracked is also used in many libraries. Examples are the classic NGRX Store, the RxJS interop mentioned above, the discussed function rxMethod, or the open source library ngxtension, which, among other things, offers a lot of helper functions for Signals.

Summary

RxJS and Signals work beautifully together and the RxJS interop offered by Angular gives us the best of both worlds. I'd recommend using RxJS to represent events. For processing asynchronous tasks, RxJS or stores, which may be based on RxJS, are a good fit. However, Signals should take over the synchronous transport of the received data into the view. Together, RxJS, Stores, and Signals are building blocks for establishing a unidirectional dataflows.

In addition, the flattening operators in RxJS are elegant solutions for preventing race conditions. Alternatively or in addition, flags can also be used to indicate whether a request is currently in progress at the backend.

Even though Signals are not primarily designed to represent events, there are cases where you want to respond to changes in a Signal. This is for instance the case when dealing with Signal-based framework APIs. In addition to the RxJS interop, the rxMethod from the Signal Store is also suitable here. Another option is the effect/untracked pattern to implement effects that only react to explicitly named signals and helper functions provided by community libraries like ngxtension.

eBook: Micro Frontends and Moduliths with Angular

Lernen Sie, wie Sie große und langfristig wartbare Unternehmenslösungen mit Angular planen und umsetzen

✓ 12 Kapitel
✓ Quellcode-Beispiele
✓ PDF, epub (Android und iOS) und mobi (Kindle)

Gratis downloaden