Angular: Imperative vs. Declarative Programming with RxJS and Signals

Author image

Jack Baker

Published: 01/11/2024

#Angular

#Signals

#RxJS

#Imperative

#Declarative


In Angular development, RxJS has long been a go-to tool for managing asynchronous data and complex event streams. With the recent introduction of Signals, Angular now offers even more options for reactive programming. Signals let us declare reactive state in a way that automatically updates templates whenever the state changes. Angular also provides an interop layer between Signals and RxJS, allowing us to seamlessly combine the flexibility and power of RxJS with the simplicity and performance of Signals.

When working with RxJS in Angular, there are two main coding approaches: imperative and declarative. In this post, we’ll explore these styles with a practical example of fetching and filtering a list of fruits.

Imperative vs. Declarative Programming

  • Imperative programming focuses on how to do things by giving detailed instructions to the program. It often involves direct control of variables and mutable state.
  • Declarative programming focuses on what outcome is needed, abstracting away some of the specifics on how to achieve it. In Angular, RxJS operators enable a declarative style that leads to simpler, more maintainable code.

Scenario: Filtering a List of Fruits

In this example, we will use a list of fruits retrieved from a service that a search input can filter.

Image

Here is the code for our fruit service:

1export interface Fruit {
2  name: string;
3  colour: string;
4}
5
6@Injectable({
7  providedIn: 'root',
8})
9export class FruitService {
10  private fruits$ = new BehaviorSubject<Array<Fruit>>([
11    { name: 'Apple', colour: 'Red' },
12    { name: 'Banana', colour: 'Yellow' },
13    { name: 'Orange', colour: 'Orange' },
14    { name: 'Grapes', colour: 'Purple' },
15    { name: 'Pineapple', colour: 'Brown' },
16    { name: 'Strawberry', colour: 'Red' },
17    { name: 'Watermelon', colour: 'Green' },
18    { name: 'Blueberry', colour: 'Blue' },
19  ]);
20
21  public getFruits(): Observable<Array<Fruit>> {
22    return this.fruits$.asObservable();
23  }
24}

Our component template code will remain the same between the two examples, here is the template code:

1<input placeholder="Filter fruits" ngModel (ngModelChange)="setSearchValue($event)"/>
2
3<ul>
4  @for (fruit of filteredFruits(); track fruit.name) {
5    <li>{{ fruit.name }}</li>
6  }
7</ul>
8

Imperative Approach

In an imperative approach, we directly mutate state and have to manually manage subscriptions. This is how it could look:

1@Component({
2  selector: 'app-imperative',
3  standalone: true,
4  templateUrl: './imperative.component.html',
5  imports: [
6    FormsModule
7  ]
8})
9export class ImperativeComponent implements OnInit, OnDestroy {
10  private readonly _fruitService: FruitService = inject(FruitService);
11  private readonly _subscriptions: Subscription = new Subscription();
12  private readonly _searchValue$: BehaviorSubject<string> = new BehaviorSubject('');
13  private _fruits: Array<Fruit> = [];
14
15  public readonly filteredFruits: WritableSignal<Array<Fruit>> = signal([]);
16
17  public ngOnInit(): void {
18    this._subscriptions.add(
19      this._fruitService.getFruits()
20        .subscribe({
21          next: (fruits) => {
22            this._fruits = fruits;
23            this.filteredFruits.set(fruits);
24          }
25        })
26    );
27
28    this._subscriptions.add(
29      this._searchValue$
30        .pipe(
31          debounceTime(300),
32          distinctUntilChanged(),
33          map((searchValue) => this._fruits.filter((fruit) => fruit.name.toLowerCase().includes(searchValue.toLowerCase()))),
34        )
35        .subscribe({
36          next: (filteredFruits) => {
37            this.filteredFruits.set(filteredFruits);
38          }
39        })
40    );
41  }
42
43  public ngOnDestroy(): void {
44    this._subscriptions.unsubscribe();
45  }
46
47  public setSearchValue(value: string): void {
48    this._searchValue$.next(value);
49  }
50}

In this imperative code:

  1. We fetch the list of fruits in ngOnInit and save it in the private _fruits field.
  2. We update the filteredFruits array whenever the input changes, manually filtering the list based on the current filter text.
  3. We handle subscription cleanup in ngOnDestroy to avoid memory leaks.

While this works, directly managing the state and subscriptions manually can make the code harder to read and maintain and will only get worse as the application grows. In total, this solution is 50 lines long.

Declarative Approach

Now, let’s rewrite this example using a declarative style with RxJS. This approach relies on various streams which are then converted to signals for use within the template.

1@Component({
2  selector: 'app-declarative',
3  standalone: true,
4  templateUrl: './declarative.component.html',
5  imports: [
6    FormsModule
7  ]
8})
9export class DeclarativeComponent {
10  private readonly _searchValue$: BehaviorSubject<string> = new BehaviorSubject('');
11  private readonly _fruitService: FruitService = inject(FruitService);
12  private readonly _fruits$: Observable<Array<Fruit>> = this._fruitService.getFruits()
13    .pipe(
14      shareReplay(1)
15    );
16  private readonly _filteredFruits$: Observable<Array<Fruit>> = combineLatest([this._fruits$, this._searchValue$])
17    .pipe(
18      debounceTime(300),
19      distinctUntilChanged(),
20      map(([fruits, searchValue]) => fruits.filter((fruit) => fruit.name.toLowerCase().includes(searchValue.toLowerCase()))),
21    );
22
23  public readonly filteredFruits: Signal<Array<Fruit> | undefined> = toSignal(this._filteredFruits$);
24
25  public setSearchValue(value: string): void {
26    this._searchValue$.next(value);
27  }
28}

Here’s how the declarative approach simplifies things:

  1. We use a BehaviorSubject called _searchValue$ to store the current filter text as an observable stream.
  2. combineLatest merges the fruits$ stream with the _searchValue$ stream so that whenever either changes, the filteredFruits$ observable recalculates the filtered list.
  3. Angular’s toSignal interop helper is being used on the public filteredFruits field and converts the filteredFruits$ stream to a signal so we can use it within our template. This interop function will automatically handle subscribing and unsubscribing to the required streams to fetch the data. This means there is no manual handling of subscriptions within our component!

The declarative code focuses on what we want the component to display. Our streams are split and are single responsibility making them small, concise and re-usable. In total, this solution is 28 lines long.

Advantages of the Declarative Approach

  1. Readability: Declarative code is often more readable, especially with RxJS operators like map and combineLatest. It expresses what we want to happen without detailed instructions on how to do it. You can also see in this particular example the amount of code required in our component was reduced from 50 lines in the imperative example to just 28 in the declarative example.
  2. Automatic Subscription Management: The toSignal interop helper in Angular automatically manages subscriptions to our streams, reducing the risk of memory leaks.
  3. Predictability: The template updates reactively whenever the data or filter text changes, making the flow of data and updates more predictable.

Disadvantages of the Declarative Approach

  1. Higher Abstraction Complexity: Declarative code abstracts away the how, focusing instead on the what. While this improves readability, it can make debugging more challenging, especially for developers unfamiliar with RxJS operators or reactive programming concepts.
  2. Steeper Learning Curve: For developers new to RxJS or functional programming, the declarative style can be difficult to grasp. Operators like map, switchMap, and combineLatest require a solid understanding of observables and stream transformations.

Conclusion

The declarative approach to RxJS in Angular offers a clean, reactive way to handle asynchronous data flows. While the imperative style works, it can become challenging to manage as applications grow more complex.

If you’re working with RxJS in Angular, try adopting a more declarative style and see how it can improve your code!