Blog
-
Tech Talks

Signals in Angular: No More RxJS Spaghetti?

Reading time: ca.
4
minutes

If you've been responsible for large JavaScript applications in production, you've probably found yourself tangled in a web of RxJS callbacks before. Don't get us wrong: it's a great library for reactive programming, and one that we use for many of our clients. However, even when you follow best practices, keeping your change detection performant and your code readable can be a real challenge.

But what if there was a simpler, faster way to handle reactivity in your Angular projects? A way to write code that's both elegant and efficient? Well, as of Angular 17 (16 if you count Developer Preview), there is! Signals is a powerful new feature that streamlines how we manage change detection and build more performant applications. In this post, we'll explore what Signals does different, dive into real-world use cases (with code!), and look at what this means for the future of Angular development.

A BREATH OF FRESH AIR

Signals offers a more direct and efficient approach to reactivity compared to traditional observables. Instead of relying on streams of data, Signals lets you define reactive values and directly observe changes. It basically gives you fine-grained control over your application's reactivity, with some significant advantages.

  • Performance
    By minimising unnecessary re-renders, Signals can significantly improve the responsiveness of your applications, especially those dealing with complex views and large datasets.
  • Syntax
    Signals doesn't aim to replace RxJS entirely (it even works alongside it), but it definitely offers a syntax that is simpler and more approachable. That makes it much easier for developers of all levels to get started with, especially if you're new to reactive programming in Angular.
  • Code clarity
    Because of the different syntax, you can say goodbye to deeply nested Observables and the complexity of certain RxJS operators (unless you're using both, of course). Signals encourage cleaner, more readable code that's easier to reason about, debug, and maintain. In short: less spaghetti, more substance.  

SIGNALS IN ACTION

Let's move from theory to practice, starting with a minor disclaimer. We're already using Signals in several applications, but not every project can (or should) adopt Signals overnight. Some of our clients don't even allow it due to some advanced Signals still being in Developer Preview. With that out of the way, let's look at some ways in which we can use this new feature.

User input in forms

One area where Signals have made a real difference is in taming complex forms. As any Angular developer knows, managing user input in forms can quickly become a mess of values that depend on each other. It's all too easy to end up with code that's difficult to read and even harder to maintain.  

Here's an example:

@Input() actualEfficiencyData!: EfficiencyData;
 @Input() numberOfPendingWorkDays!: number;
 // You can use BehaviorSubjects to maintain and update values reactively.
 estimatedEfficiency = new BehaviorSubject<number>(0);
 estimatedWorkingHours = new BehaviorSubject<number>(0);
 estimatedRouting = new BehaviorSubject<number>(0);
 estimatedMonthlyPrice = new BehaviorSubject<number>(0);
 canCalculateEstimatedEfficiency$ = combineLatest([
   this.estimatedRouting,
   this.estimatedWorkingHours
 ]).pipe(
   map(([estimatedRouting, estimatedProductivity]) => estimatedRouting > 0 && estimatedProductivity > 0)
 );  
 estimatedProductivity$ = combineLatest([
   this.estimatedWorkingHours,
   new BehaviorSubject(this.numberOfPendingWorkDays) // assuming it's coming from @Input or similar
 ]).pipe(
   map(([estimatedWorkingHours, numberOfPendingWorkDays]) => estimatedWorkingHours * numberOfPendingWorkDays)
 );
 totalProductivityTime$ = combineLatest([
   this.estimatedProductivity$,
   new BehaviorSubject(this.actualEfficiencyData.productiveTime)  
 ]).pipe(
   map(([estimatedProductivity, productiveTime]) => estimatedProductivity + productiveTime)
 );
 totalRouting$ = combineLatest([
   this.estimatedRouting,
   new BehaviorSubject(this.actualEfficiencyData.routing) // assuming actualEfficiencyData is @Input
 ]).pipe(
   map(([estimatedRouting, routing]) => estimatedRouting + routing)
 );

Signals offer a much cleaner solution. It lets us directly observe and react to changes in individual form fields, making it significantly easier to implement complex form logic in a way that's both performant and understandable.

Here's what that same RxJS code snippet looks like with the Signals functionality:

actualEfficiencyData = input.required<EfficiencyData>();
numberOfPendingWorkDays = input.required<number>();
estimatedEfficiency = input<number>(0);

estimatedWorkingHours = model<number>(0);
estimatedRouting = signal<number>(0);
estimatedMonthlyPrice = signal<number>(0);

canCalculateEstimatedEfficiency = computed(() => this.estimatedRouting() > 0 && this.estimatedProductivity() > 0);
estimatedProductivity = computed(() => this.estimatedWorkingHours() * this.numberOfPendingWorkDays());

totalProductivityTime = computed(() => this.estimatedProductivity() + this.actualEfficiencyData().productiveTime);
totalRouting = computed(() => this.estimatedRouting() + this.actualEfficiencyData().routing);  

But that's not all – things can get even better. If you use effect(), you can use one of the computed signals (calculated based on user input) to send out API calls that retrieve additional information.

constructor() {
 effect(() => {
   if (this.canCalculateEstimatedEfficiency()) { // signal that is computed based on the input of two other fields that can be changed by the user
      // Do extra call to backend to fecth info
   }
 });
}

Without Signals, the RxJS code to achieve the same is more complex. Keep in mind that you'll also have to make sure that everything gets unsubscribed, or you'll risk causing memory leaks.

// Subscribe to changes in canCalculate$ and trigger getEstimatedEfficiency when condition is met  
this.subscription = this.canCalculate$.pipe( distinctUntilChanged(), // Only emit when the value changes  
filter(canCalculate => canCalculate) // Proceed only if true ).subscribe(() => {  
this.getEstimatedEfficiency();
});  

Component communication

Another area we're exploring is Signal-based component communication. While this is still in developer preview, it has the potential to be a game-changer. It makes passing data between parent and child components is as simple as setting a value.  

Without Signals, you had to use ‘!’ as a definitive assignment assertion, telling TypeScript that a value would be assigned. You also had to wrestle with @Input and @Output:

@Input({required = true}) total!: number;
@Input({required = true}) targetEfficiency!: number;
@Input({required = true}) difference!: number;
With Signals, the syntax becomes a lot shorter and more readable.
total = input.required<number>();
targetEfficiency = input.required<number>();
difference = input.required<number>();

What if you still need (or want) to use Observables?

Of course, we know that not every project can (or should) adopt Signals overnight. Many existing applications rely heavily on Observables, and refactoring everything at once simply isn't practical. That's where the beauty of Signals' interoperability with RxJS comes in.

The toSignal function provides a bridge between the two worlds. It lets us convert Observables to Signals, making migrations easier. In other words: we can start incorporating Signals into new features or specific parts of our applications without having to rewrite everything from scratch.

Here's an example to make things clear. On a service level, you can use Observables to retrieve data from the backend.

getActiveOptions(): Observable<Option[]> {
return this.httpClient.get<ActiveOptionsResponse>(`${this.mockUrl}/users/active/active-options.json`)
.pipe(map(response => convertArrayToOptions(response.data)));
}
While on the component level, you can use Signals:
getUserActiveOptions(): Signal<Option[]> {
 return toSignal(this.userService.getActiveOptions(), { initialValue: [] });
}

LOOKING AHEAD

So, what does the future hold for Signals? We're excited to see how this new feature continues to evolve alongside Angular. There are a few interesting things on the horizon that we're keeping a close eye on.

For one, Angular might finally be ready to ditch Zone.js! It has been a core dependency for a long time, but let's just say it's not without its quirks (and performance overhead). We think that Signals are an important step towards a Zone-less Angular, potentially leading to even faster change detection and a smoother development experience.

We also expect the Angular team to continue improving and refining Signals themselves. As more developers start using them in real-world projects, new use cases and challenges will emerge. This will (hopefully) lead to more robust and feature-rich implementations in future Angular releases. Definitely something to look forward to!

READY TO DITCH THE SPAGHETTI?

Signals offer a great new way to manage reactivity in your Angular applications, bringing performance gains, code clarity, and a more intuitive developer experience. They're still pretty new, but they have the potential to fundamentally change how we build Angular applications, and we're excited to see what the future holds.

If you'd also like to work in an environment where you get to explore the latest technologies in everything related to Java and JavaScript, take a look at our Careers page!

Kristof De Bock

October 21, 2024

Read the highlights of our blog

"Each project pushes our skills farther and expands our expertise"

Let's talk!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
We value your privacy! We use cookies to enhance your browsing experience and analyse our traffic.
By clicking "Accept All", you consent to our use of cookies.