As we discussed in our previous Upgrading from Angular 16 to 20 Guide, the upgrade to Angular 17+ will bring on the possibility of converting your app’s observables to signals. You want to use signals for multiple reasons rather than using observables. I came across these excellent reasons on why to employ signals while reading the book Modern Angular by Armen Vardanyan. This blog will recap the advantages of using signals as excellently explained by Mr. Vardanyan. If you find this guide useful, please consider visiting his website and purchasing his book.

Let’s begin by going over the multiple advantages of employing Signals in Angular 17+. One reason why you will want to use signals rather than observables in your components is the simplicity they provide. Unlike Observables, signals are simple to manipulate and offer a less steep learning curve. The representative state of a signal, rather than the event driven observable, is another advantage of using signals. Lastly, another very good point I read about in the Modern Angular book, is the fact that async and synchronous observables ought to cause confusion in a component.

You can learn more in-depth about these three points I will be discussing by purchasing and reading a copy of the Modern Angular book by Armen Vardanyan. There are other advantages that you learn about while reading the Modern Angular book. By reading the book, you will also go in-depth into many more signal topics that are not discussed in this guide. To make this article a quick read, I will simply list the advantages that Mr. Vardanyan goes into detail here:

  1. “Values can always be read.”
  2. “Reading the value does not affect the application in any way.”
  3. “Values can be changed on the fly.”
  4. “Everything is Synchronous.”
  5. “We can perform side effects based on changes of the value.”
  6. “We can create new reactive values from existing ones.”
  7. “Unsubscriptions will be automatic.”
  8. “It should interoperate with RxJs.”

This is quite the list, and each bullet point has a section dedicated to it in the book. I will go over the three main points I listed before, while also touching a bit on how these eight bullet points above relate to those three points. This article is simply a recap of what I have learned from reading Modern Angular by Mr. Vardanyan.

While all these reasons to use signals might seem vague at first, and not really worth going over the trouble of switching over to signals, it is vital to note that the accumulation of all these nuances end up making a big difference in the reactive programming paradigm, where we are reacting to events rather than performing actions in the order in which they are declared. Not only is the paradigm different enough already from the functional programming one, but there are also added layers of complexity such as subscriptions and RxJS operators. Now, let’s explore the simplicity of signals vs observables.

While discussing the simplicity of signals vs observables, it is important to note that under the hood, signals do a lot of what observables do, including subscriptions. However, all of these nuances are handled automatically. For example, if you have a computed signal that relies on another signal for displaying a new value, updating the signal also automatically updates the computed signal’s value. This eliminates the need for managing BehaviorSubjects, Subscriptions, and even change detection in non-zone based components.

One of the main points that the author goes over in the Modern Angular book is the learning curve that observables present to the programmer. If you have not dealt with observables before, you will face this issue. First, you will have to learn about reactive programming, then you will need to learn about the concept of streams, pipes, operators, and subjects. On top of all that you will also need to manage subscriptions. Personally having been in that position a couple of years ago, I must say that these tasks are nightmarish.

So how do signals simplify that? I will go over this example and explain this to you.

  • Defining a Signal vs an Observable.
    • You can see from the get-go that the definition of a signal does not require the programmer to delve into RxJs, eliminating the need for a new developer to learn about another library.
    // Mock book list
    mockBookList: string[] = ['a', 'b', 'c'];
    
    // Defining an Observable
    bookList$: Observable<string[]> = of(mockBookList);
    
    // Defining a signal
    bookList: WritableSignal<string[]> = signal(mockBookList);
    
  • Reading a value from a signal
    • No subscriptions
    • No more async pipes in the template
    • No need to have multiple variables
    • No need for boilerplate
    
    // Reading from an observable and updating a value.
    #modifiedBookList: Book[] = [];
    bookList$.subscribe(a => this.#modifiedBookList = a.filter(b => b.id !== 1));
    
    // Reading from a signal and updating a value.
    #modifiedBookList: Book[] = [];
    this.modifiedBookList = this.bookList().filter(b => b.id !== 1));
    
    
In the above example, you can see the reduction of knowledge and code needed to perform something as trivial as reading and updating a value. Not only will you not delve into subscriptions, additionally, you will still be able to read the bookList from the template with the same intent of that as reading an observable, without knowledge of the async pipe.
<!-- Reading an Observable from template -->
<div *ngFor="let book of (bookList$ | async)">
  <h4>{{ book.title }}</h4>
  <p>{{ book.author }}</p>
  <p>{{ book.excerpt }}</p>
</div>

<!-- Reading a signal from template -->
<div *ngFor="let book of bookList()">
  <h4>{{ book.title }}</h4>
  <p>{{ book.author }}</p>
  <p>{{ book.excerpt }}</p>
</div>

These were some of the more notable advantages for newcomers I picked up while reading the Modern Angular by Armen Vardanyan  book.

The Representative State of a Signal

Now let’s talk about another advantage I learned about signals while reading Modern Angular. We touched on how the representative state of a signal simplifies the reactive paradigm a bit on the previous example. While event driven programming is nice, you also have to account for its reactive state representation. That is, in order to read a value, you will have to create a stream that subscribes to changes in your source of truth and derive that value somewhere. For example,

// Mock book list
mockBookListResponse: {status: string, bookList: Book[]} = 
         {status: 'ok', bookList: [{id: 1, title: 'a'}, {id: 2, title: 'b'}, {id: 3, title: 'c'}]};
modifiedBookList: Book:[];
booksToCheckOut: Book:[];

// Defining the Observable
bookList$ = new Observable<string[]>();
constructor() {
  this.bookList$ = of(mockBookListResponse);
  bookList$.subscribe((res) => {
    this.bookList = res.bookList;
  });
}

// Find an ID in the book list
searchForId(id: number): Book | null {
  return bookList.filter(b => b.id === id);
}

As you can see, we had to subscribe to the stream and derive the value it represents to a variable. This gets quite annoying and repetitive. Now you are tracking two different values throughout your component’s code and affecting every stream that depends on that observable. Debugging this can get annoying. Also, if you find yourself having to modify a variable in the bookList$ subscription callback for some reason, like when adding a discount code to the totalPrice of the books,  it would cause the totalPrice variable to depend on multiple sources of truth. You are now deriving an additional value from this source of truth. That would cause the variables in you code to grow and create confusion as to where their source of truth is.

Let’s think of this for a moment; If you have a bookList, and you want to also display the accumulated price for the books in the list plus an added discount, what will be the approach? We’d have to perform a couple of RxJs operations to calculate the total price of your book list. This will require the user to have significant knowledge of the RxJs library.

With Observables, the operation would look like this:

constructor() {
  this.booksService.getAvailableBooks().pipe(
    takeUntilDestroy(),
    tap(a => {
      this.booksAvailableList = a;
      this.booksAvailableList$.next(a);
      this.updateBookPriceTotal();
    }), 
  ).subscribe();

  this.booksService.getDiscountCode().pipe(
    takeUntilDestroy(),
    startWith({code: '', percent: 0}),
    tap(a => {
      this.discountCode = a;
      this.discountCode$.next(a);
    }), 
  ).subscribe();

  combineLatest([
    this.booksAvailableList$,
    this.discountCode$,
  ]).pipe(
    takeUntilDestroyed(),
    map([_booksAvailable, _discountCode]) => {
     this.updateBookPriceTotal();
    })
  ).subscribe();
}

updateBookPriceTotal() {
  this.booksAvailableList.forEach((a) => { 
    this.totalPrice += a.price;
  });
  this.totalPrice = this.totalPrice - (this.totalPrice * this.discountCode.percent);
}

This would be the scenario when doing something like adding a discount code. There are many intricacies with this code. We have to manage subscriptions, account for extra variables that are BehaviorSubjects, and account for representative values like the booksAvailable array. Notice that the big problem here is that we can’t access these values in a representative manner. Instead, we are reacting to the streams which is why we need the behavior subjects and the introduction of representative variables. You will also notice that we are calling updateBookPriceTotal() twice… because we’d want to update the price when the discountCode arrives from the HTTP call as well as having the price when the discountCode response has not arrived from the server. We don’t call the updateBookPriceTotal() in the getDiscountCode() subscription’s next() callback because why would we want to calculate the discount if we still don’t have a price? Also, notice the need for the startWith() operator. 

Below, you can see how the representative states of signals, by using a computed signal which relies on the booksAvailableList and the discountCode, simplifies this algorithm. Notice you do not need to put any variable definition inside a subscription callback.

booksAvailableList = toSignal(
  this.booksService.getAvailableBooks().pipe(
   map(resp => resp.booksAvailableList)
  )
);

discountPercent = toSignal(
  this.booksService.getDiscountCode().pipe(
    map(resp => resp.discount.percent)
  )
);

bookPriceTotal = computed(() => {
  let totalPrice = 0; 
  let bookPrice = this.bookAvailableList().forEach(a => totalPrice += a.price); 
  totalPrice = totalPrice - (this.discountPercent() * totalPrice);
  return totalPrice;
});

Using a computed signal as you see above, will automatically update the bookPriceTotal, without the need of a subscription, a combineLatest, other RxJs operators, or the introduction of an additional function. Whenever the booksAvailableList or the discountCode are updated, the signal will update. I think the best part about this is that you don’t have to manage the subscription to the computed signal at all. If you make a call to either booksService.getAvailableBooks() or booksService.getDiscountCode(), the computed signal will update automatically! Look how much shorter the code is.

Confusion created by Async. and Synchronous Observables

Adding to the steep learning curve of observables, is the confusion generated by the non-emission and race conditions introduced by the combination of async and synchronous observables (like when using combineLatest). I personally remember running into this numerous times while programming reactive features. Such streams can get confusing for developers that are just learning the paradigm. This is perfectly explained by Armen Vardanyan is his book Modern Angular:

“…but the form.allowDuplicates.valueChanges observable is synchronous, meaning that its combination might have to wait a while before it actually emits a value. Thus, even if the user has clicked on the checkbox, the button will not appear until they input some characters. This can be mitigated by using the startsWith operator and putting some default value into the streams that we combine, but this introduces more complexity, and in a large component such things can easily get overlooked only to then become bugs that are really hard to trace and fix.”

To further validate this, in this next example I came up with, let’s say that you have a combineLatest operator to represent a notificationList$ observable. This operator will be used in order to retrieve a newNotificationsList$ stream to get incoming notifications from the system and an existingNotificationList$ stream that provides the content of the existing notifications.

  1. We need to access the new notifications list as they are broadcasted. We need this in order to display the new notifications to the logged in user because this is a time-sensitive application. This means that we will need a newNotificationsList$ observable.

  2. We want the lists of the existing notifications for each notification category (All, Read, Unread, Employee, Human Resources). For this, we will need an existingNotificationsList$ observable.

We want to be able to display the notifications in one list rather than two separate lists. Instead of having two lists (new notifications, and existing notifications) for the All, Unread, Read, Employees, and Human Resources categories, we will just have one. That one list is a combination of the new and existing notifications lists. We’ll push the new notifications to the start of the newNotificationsList$, and the combineLatest stream will be in charge for filtering the new and existing notifications correctly. Finally we’ll save the combined filtered notification list in the notificationList$ BehaviorSubject. 

If you have ever dealt with filtering and sorting, then you know why we are approaching this in this manner rather than just leaving and displaying the two lists, with the new notifications list being displayed first followed by the existing notifications list. Here’s how all of this would look like if we were using observables.

  newNotificationsList$ = new Observable<Notification[] | null>();
  existingNotificationsList$ = new Observable<Notification[] | null>();
  notificationsList$ = new BehaviorSubject<Notification[] | null>(null);

  filter$ = new BehaviorSubject({name: 'all'});
  type$ = new BehaviorSubject({name: 'all'});

  constructor() { 
    this.newNotificationsList$ = of();
    this.existingNotificationsList$ = of();

    combineLatest([
      this.newNotificationsList$,
      this.existingNotificationsList$,
      this.filter$.pipe(tap(a => console.log('filter pipe', a))),
      this.type$.pipe(tap(a => console.log('type pipe', a))),
    ]).pipe(
      takeUntilDestroyed(),
      tap(([newNotificationList, existingNotificationList, filter, type]) => {
        
        console.log('newNotificaitonList', newNotificationList);
        console.log('existingNotificationList', existingNotificationList);
        console.log('filter', filter);
        console.log('type', type);

        let notificationList: Notification[] = [];

        if (newNotificationList && existingNotificationList) {
          notificationList = [...newNotificationList, ...existingNotificationList];
          notificationList = notificationList.filter((notification) =>
             return (notification.status.name === this.status().name && 
                  notification.type.name === this.type().name) || 
                  (!this.status().name && notification.type.name === this.type().name) || 
                  (!this.type().name && notification.status.name === this.status().name)
        }

        this.notificationsList$.next(notificationList);
      }),
    ).subscribe();
  }

A good way to explain this is to look at all the things that go wrong with this combineLatest() . Since this section focuses on looking at the confusion created by Async and Synchronous observables, let’s just focus on non-emissions, race conditions, and debugging. 

If you remember the first time you used an asynchronous observable with the combineLatest operator, that is an observable that does not initially emit any value, then you could run into the possibility of wondering “Why are my pipe’s operators not running their code?”. This would be the case with the above scenario. The filter$ and sort$ observables would emit a value, however, if the newNotificationList$ and existingNotificationList$ for reasons like a network delay or non-default value, you’ll be stuck wondering why your pipe is not running. Look at the pipes in the combineLatest(), you see those tap operators added that log to the console ‘filter type’ and ‘type pipe’? Not a lot of developers know about how these work for debugging. Look at also how all the console logs in the tap operator of the combineLatest() that don’t appear in the console. This is because the pipe is not running, and it’s not running due the the non-emission of newNotificationList$ and existingNotificationsList$ observables. Instead, try changing:

    this.newNotificationsList$ = of();
    this.existingNotificationsList$ = of();
to
    this.newNotificationsList$ = of(null);
    this.existingNotificationsList$ = of(null);

You will now see that everything is logging to the console. I’m simplifying this with the of() operator. However, in a real life scenario where you’ll be making an http call or waiting on a value for the asynchronous observable to emit, you’d be left wondering what to do. One thing you could do is use a startWith() operator.

    let emptyNotification: Notification[] = [{text: '', id: 0, type: {name: ''}, status: {name: ''}}];

    this.newNotificationsList$ = this.pusherService.listen().pipe(
      startWith(emptyNotification)
    );

    this.existingNotificationsList$ = this.http.get('api/unresolved').pipe(
      startWith(emptyNotification)
    );

In terms of race conditions, let’s say that for some reason you have a user$ stream that emits the logged-in user’s profile and you are expecting it in the combineLatest(). Let’s say you want the user profile object because it contains the user’s profile slug which you will need to navigate to when you click on the notification. Logic says you’ll want to append the user.profileName to the Notification object in the tap operator of the combineLatest(). What happens if the user$ observable failed to make the API call and a developer set the request’s http.get()’s subscription error callback to map to null instead of default user object? This is a race condition because you will have all the other objects available in your combineLatest()’s pipe except for the one from the user$ stream which is null.

This brings us to the next point which is the fact that this is a hard error to debug. A new developer might look at the api response, which is null, and completely overlook the observable’s error callback from where you’d want to map a default user object for the error instead of null. In this scenario, developers will start to make all kinds of checks in the subscription’s pipe’s tap operator instead of addressing the real issue which is the fact that the error callback was never configured correctly.

Let’s see how using signals alleviates the situation by setting defaults for non-emissions of asynchronous observables, all the while avoiding deeper knowledge of race conditions, subscriptions, their callbacks, and the intricacies of operators like combineLatest and the RxJs library itself.

  newNotificationsList: Signal<Notification[]>;
  existingNotificationsList: Signal<Notification[]>;
  
  status = signal({name: NotificationStatusName.READ});
  type = signal({name: NotificationTypeName.EMPLOYEE});

  notificationsList: Signal<Notification[] | undefined>;

  constructor(private http: HttpClient) { 

    this.newNotificationsList = toSignal(
      of(MOCK_NOTIFICATION_LIST),
      { initialValue: EMPTY_NOTIFICATION_LIST }
    );

    this.existingNotificationsList = toSignal(
      of(MOCK_NOTIFICATION_LIST),
      { initialValue: EMPTY_NOTIFICATION_LIST }
    );

    this.notificationsList = computed<Notification[]>(() => {
      return [
        ...this.newNotificationsList(), 
        ...this.existingNotificationsList()
      ].filter(notification => { 
          return (notification.status.name === this.status().name && 
                    notification.type.name === this.type().name) || 
                    (!this.status().name && notification.type.name === this.type().name) || 
                    (!this.type().name && notification.status.name === this.status().name)
        });
    });

    console.log("Notification List", this.notificationsList());

    setInterval(() => {
      this.status.update(() => (this.randomStatus()));
      this.type.update(() => (this.randomType()));
      console.log("NOTIFICATION STATUS", this.status());
      console.log("NOTIFICATION TYPE", this.type());
      console.log("Notification List", this.notificationsList());
    }, 4000);
  }

  randomStatus() {
    return [{name: NotificationStatusName.READ}, {name: NotificationStatusName.UNREAD}, {name: null}][Math.floor(Math.random() * 2)];
  }

  randomType() {
    return [{name: NotificationTypeName.EMPLOYEE}, {name: NotificationTypeName.HR}, {name: null}][Math.floor(Math.random() * 2)];
  }

Here are the type definitions for both examples if you want to run it in your IDE

interface Notification {
  text: string,
  id: number,
  type: NotificationType,
  status: NotificationStatus,
}

enum NotificationStatusName {
  READ='read',
  UNREAD='unread'
}

enum NotificationTypeName {
  HR='human_resources',
  EMPLOYEE='employee',
}

interface NotificationStatus {
  name: NotificationStatusName | null
}

type NotificationType = {
  name: NotificationTypeName | null
}

const EMPTY_NOTIFICATION_LIST: Notification[] = [{text: '', id: 0, type: {name: null}, status: {name: null}}];

const MOCK_NOTIFICATION_LIST: Notification[] = [
  {text: 'Notification 1', id: 1, type: {name: null}, status: {name: null}},
  {text: 'Notification 2', id: 2, type: {name: NotificationTypeName.HR}, status: {name: NotificationStatusName.UNREAD}},
  {text: 'Notification 3', id: 3, type: {name: NotificationTypeName.EMPLOYEE}, status: {name: NotificationStatusName.READ}},
  {text: 'Notification 4', id: 4, type: {name: null}, status: {name: NotificationStatusName.UNREAD}},
];
Beside all the logging, and the ugly filter, look how much shorter and easier to manage this code using signals is! The computed signal updates automatically whenever one of the signals it depends on updates. The Interval I’ve set here should allow you to see that whenever you are updating one of the signals that the computed signal depends on, the value of the computed signal also updates. Notice how there are no operators, no race conditions, no non-emissions, and no debugging. Also notice how you can relate the code to the list Armen Vardanyan mentions in his book:

  1. “Values can always be read.”
  2. “Reading the value does not affect the application in any way.”
  3. “Values can be changed on the fly.”
  4. “Everything is Synchronous.”
  5. “We can perform side effects based on changes of the value.”
  6. “We can create new reactive values from existing ones.”
  7. “Unsubscriptions will be automatic.”
  8. “It should interoperate with RxJs.”


The above code demonstrating the use of signals and the toSignal() function covers all of these, except the “We can perform side effects based on changes of the value.” bullet-point. That has to do with the effects wrapper which I will not cover here.
Hence, the introduction of signals in Angular saves us a ton of headaches through debugging, boilerplate, and subscription management. From making it simpler to onboard new developers through less complexity, to giving us a way to deal with code in a representative manner, to allowing us reduced intricacies by eliminating race conditions and non-emissions of some observables. Remember, if you really want to learn more about this topic, I highly recommend purchasing the Modern Angular book by Armen Vardanyan.

Leave a Reply

Your email address will not be published. Required fields are marked *