Signals make Angular MUCH easier

Mike Pearson - Jul 18 '23 - - Dev Community

YouTube

Why shouldn't RxJS do everything?

RxJS is amazing, but it has limitations. Consider a counter implemented using a simple variation of the "Subject in a Service" approach:

export class CounterService {
  count$ = new BehaviorSubject(0);

  increment() {
    this.count$.next(this.count$.value + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now multiple components can share and react to this state by subscribing to count$:

Image description

Now let's add some derived states with the RxJS map and combineLatest operators:

  count$ = new BehaviorSubject(1000);

  double$ = this.count$.pipe(map((count) => count * 2));
  triple$ = this.count$.pipe(map((count) => count * 3));

  combined$ = combineLatest([this.double$, this.triple$]).pipe(
    map(([double, triple]) => double + triple)
  );

  over9000$ = this.combined$.pipe(map((combined) => combined > 9000));

  message$ = this.over9000$.pipe(
    map((over9000) => (over9000 ? "It's over 9000!" : "It's under 9000."))
  );
Enter fullscreen mode Exit fullscreen mode

Here's a diagram of these reactive relationships:

Image description

Here's what that looks like:

Shared Counter State 2

Isn't this easy? RxJS just takes care of everything for us. There's probably nothing wrong with this.

Actually there is. Let's put a console log inside the map for message$ and see what happens when we increment the count once.

  message$ = this.over9000$.pipe(
    map((over9000) => {
      console.log('Calculating message$', over9000);
      return over9000 ? "It's over 9000!" : "It's under 9000.";
    })
  );
Enter fullscreen mode Exit fullscreen mode

Image description

Why did it run 4 times? We only incremented the count once. That's not efficient.

Something weird is going on. Let's put console logs inside each observable so we can get a view into everything happening. And think for a minute about what we should expect. We have a single event, and 5 derived states: double$, triple$, combined$, over9000$, and message$. Shouldn't we see 5 console logs? Well, here's what we actually get:

Image description

It's over 9000!!! We just implemented our feature in the simplest way possible, and this is what RxJS gave us. This is 40 logs, or 8x what it should be.

We need to understand how subscriptions work. We have 2 components subscribing to several of these observables. Here I've added a colored line for each subscription:

Image description

Each subscription gets passed all the way up to the top of the chain. If you count the number of blue and green lines next to double$ and triple, it's 8 each. That's the number of console logs for each of those. combined$ has 12 lines around it (because of the branching), and 12 logs. But message$ has 2 lines and not 2 but 4 console logs, and over9000$ has 4 lines but 8 console logs. That's because each of those lines ends up splitting into 2 lines up at the combineLatest.

We have to learn more operators to deal with these problems: map and distinctUntilChanged (sometimes with a comparator), combineLatest and debounceTime, and shareReplay. Actually, not shareReplay, more like publishReplay and refCount. Or actually, merge, NEVER, share and ReplaySubject (more on these later). The really crazy thing is that most people aren't even aware of all these issues. It takes some painful experiences to learn that these operators are necessary.

But asking everyone to avoid the numerous RxJS pitfalls, become intimately familiar with how subscriptions work, and learn all these operators, all just for basic derived state, is absurd. And, these operators increase bundle size and do work at runtime. Creating a custom operator doesn't fix that.

So, while RxJS is amazing for managing asynchronous event streams, it is inefficient and difficult to use for synchronizing states.

How about selectors?

Selectors are pretty efficient at computing derived states.

But I never liked their syntax:

createSelector(
  selectItems,
  selectFilters,
  (items, filters) => items.filter(filters),
);
Enter fullscreen mode Exit fullscreen mode

So for StateAdapt I came up with new syntax:

buildAdapter<State>(...)({
  filteredItems: s => s.items.filter(s.filters),
})();
Enter fullscreen mode Exit fullscreen mode

But selectors require a state management library with a global state object, which makes them impossible to integrate tightly with framework APIs, such as component inputs.

Signals

Angular needed a reactive primitive of its own, and out of all the options, signals were the best choice for synchronization.

Let's implement our counter with Angular signals:

  count = signal(1000);

  double = computed(() => this.count() * 2);
  triple = computed(() => this.count() * 3);

  combined = computed(() => this.double() + this.triple());

  over9000 = computed(() => this.combined() > 9000);

  message = computed(() =>
    this.over9000() ? "It's over 9000!" : "It's under 9000."
  );
Enter fullscreen mode Exit fullscreen mode

Now when we click, we get the 5 expected logs:

Image description

It's more efficient than even optimized RxJS, and we only needed one "operator": computed.

The Angular team did an amazing job with the implementation, too. If you want to learn more about how it works, I recommend this interview they did with Ryan Carniato.

Problems with signals

Signals are awesome, but like RxJS, they have limitations:

  1. Asynchronous Reactivity
  2. Eager & Stale State

These will be the topics of my next articles.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .