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.
In this example, we will use a list of fruits retrieved from a service that a search input can filter.
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
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:
ngOnInit
and save it in the private _fruits
field.filteredFruits
array whenever the input changes, manually filtering the list based on the current filter text.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.
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:
BehaviorSubject
called _searchValue$
to store the current filter text as an observable stream.combineLatest
merges the fruits$
stream with the _searchValue$
stream so that whenever either changes, the filteredFruits$
observable recalculates the filtered list.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.
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.toSignal
interop helper in Angular automatically manages subscriptions to our streams, reducing the risk of memory leaks.map
, switchMap
, and combineLatest
require a solid understanding of observables and stream transformations.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!