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 a 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:
export interface Fruit {
name: string;
colour: string;
}
@Injectable({
providedIn: 'root',
})
export class FruitService {
private fruits$ = new BehaviorSubject<Array<Fruit>>([
{ name: 'Apple', colour: 'Red' },
{ name: 'Banana', colour: 'Yellow' },
{ name: 'Orange', colour: 'Orange' },
{ name: 'Grapes', colour: 'Purple' },
{ name: 'Pineapple', colour: 'Brown' },
{ name: 'Strawberry', colour: 'Red' },
{ name: 'Watermelon', colour: 'Green' },
{ name: 'Blueberry', colour: 'Blue' },
]);
public getFruits(): Observable<Array<Fruit>> {
return this.fruits$.asObservable();
}
}
Our component template code will remain the same between the two examples, here is the template code:
<input placeholder="Filter fruits" ngModel (ngModelChange)="setSearchValue($event)"/>
<ul>
@for (fruit of filteredFruits(); track fruit.name) {
<li>{{ fruit.name }}</li>
}
</ul>
In an imperative approach, we directly mutate state and have to manually manage subscriptions. This is how it could look:
@Component({
selector: 'app-imperative',
standalone: true,
templateUrl: './imperative.component.html',
imports: [
FormsModule
]
})
export class ImperativeComponent implements OnInit, OnDestroy {
private readonly _fruitService: FruitService = inject(FruitService);
private readonly _subscriptions: Subscription = new Subscription();
private readonly _searchValue$: BehaviorSubject<string> = new BehaviorSubject('');
private _fruits: Array<Fruit> = [];
public readonly filteredFruits: WritableSignal<Array<Fruit>> = signal([]);
public ngOnInit(): void {
this._subscriptions.add(
this._fruitService.getFruits()
.subscribe({
next: (fruits) => {
this._fruits = fruits;
this.filteredFruits.set(fruits);
}
})
);
this._subscriptions.add(
this._searchValue$
.pipe(
debounceTime(300),
distinctUntilChanged(),
map((searchValue) => this._fruits.filter((fruit) => fruit.name.toLowerCase().includes(searchValue.toLowerCase()))),
)
.subscribe({
next: (filteredFruits) => {
this.filteredFruits.set(filteredFruits);
}
})
);
}
public ngOnDestroy(): void {
this._subscriptions.unsubscribe();
}
public setSearchValue(value: string): void {
this._searchValue$.next(value);
}
}
In this imperative code:
We fetch the list of fruits in ngOnInit
and save it in the private _fruits
field.
We update the filteredFruits
array whenever the input changes, manually filtering the list based on the current filter text.
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.
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.
@Component({
selector: 'app-declarative',
standalone: true,
templateUrl: './declarative.component.html',
imports: [
FormsModule
]
})
export class DeclarativeComponent {
private readonly _searchValue$: BehaviorSubject<string> = new BehaviorSubject('');
private readonly _fruitService: FruitService = inject(FruitService);
private readonly _fruits$: Observable<Array<Fruit>> = this._fruitService.getFruits()
.pipe(
shareReplay(1)
);
private readonly _filteredFruits$: Observable<Array<Fruit>> = combineLatest([this._fruits$, this._searchValue$])
.pipe(
debounceTime(300),
distinctUntilChanged(),
map(([fruits, searchValue]) => fruits.filter((fruit) => fruit.name.toLowerCase().includes(searchValue.toLowerCase()))),
);
public readonly filteredFruits: Signal<Array<Fruit> | undefined> = toSignal(this._filteredFruits$);
public setSearchValue(value: string): void {
this._searchValue$.next(value);
}
}
Here’s how the declarative approach simplifies things:
We use a 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.
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.
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.
Automatic Subscription Management: The toSignal
interop helper in Angular automatically manages subscriptions to our streams, reducing the risk of memory leaks.
Predictability: The template updates reactively whenever the data or filter text changes, making the flow of data and updates more predictable.
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.
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.
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!
Copyright © 2024 Jack Baker