From Imperative to Reactive Thinking
RxJS brings a declarative paradigm to Angular development, though the framework's object-oriented roots often nudge developers toward imperative patterns. The approach outlined in the previous article offers a remedy: always consider how state influences the UI and how to transform available state into displayable state. To recap the core principles from Part I, tackling any problem requires:
- Identifying which portion of the state influences the UI and converting it into an Observable stream;
- Applying RxJS operators to compute and derive the final state destined for the template;
- Utilizing the
asyncpipe to inject the computed result directly into the view.
Let us examine a scenario that many junior Angular developers would instinctively solve imperatively. Suppose a component displays the current time in a user-selectable format (12-hour versus 24-hour), controlled via a dropdown. We also have a formatTime function that takes a time value and a format string, returning a formatted, display-ready string. Here is the conventional, imperative implementation:
@Component({
selector: 'app-example',
template: `
<select [(ngModel)]="format">
<option value="24h">24 Hour</option>
<option value="ampm">AM PM</option>
</select>
{{ formattedTime }}
`,
})
export class ExampleComponent implements OnInit {
format: 'ampm' | '24h' = '24h';
formattedTime: string;
interval: number;
ngOnInit() {
this.interval = setInterval(() => {
this.formattedTime = formatTime(new Date(), this.format);
}, 900)
}
ngOnDestroy() {
clearInterval(this.interval);
}
}
In this version, ngOnInit sets up an interval that recalculates the time every 900 milliseconds and assigns it to a property for the view. Upon component destruction, clearInterval is invoked to halt the timer.
While this code functions, we can do significantly better by leveraging ReactiveForms and Observables to achieve a fully declarative component.
@Component({
selector: 'app-example',
template: `
<select [formControl]="format">
<option value="24h">24 Hour</option>
<option value="ampm">AM PM</option>
</select>
{{ formattedTime$ | async }}
`,
})
export class ExampleComponent {
format = new FormControl('24h');
formattedTime$ = combineLatest(
interval(900).pipe(map(() => new Date())),
this.format.valueChanges.pipe(startWith('24h')),
).pipe(
map(([dateTime, format]) => formatTime(dateTime, format)),
);
}
The approach here shifts to state-centric thinking. We need to display the time, but what determines its value? It depends on the current moment (source: interval) and the format selected by the user (source: valueChanges). We combine these two streams and map them to the final string for display. The benefits are manifold:
- The component's code is shorter by six lines, leading to fewer potential defects.
- There are no methods to manage. Methods, especially complex ones, can become imperative and convoluted. Eliminating them is a potent strategy for reducing complexity and bugs.
- The component is inherently declarative; the class consists only of self-describing properties.
- As a bonus, we no longer need to implement
ngOnDestroy; theasyncpipe manages its own subscription cleanup.
But what about problems that require more intricate or bespoke logic?
Harnessing the Full Potential of RxJS
You will often hear RxJS enthusiasts declare that "everything is a stream." This is not mere hyperbole. Even a single, static value is a stream. Even the absence of a value is a stream. Adopting this mindset fundamentally simplifies frontend development because it is inherently reactive. An event occurs; we must react immediately, updating the view and state, perpetually. It is more productive to envision a list of users not as a static snapshot, but as a stream encompassing all past, present, and future lists, and to build your logic around that dynamic reality.
Consider a form laid out in two columns, with some fields on the left and others on the right. The challenge arises from custom fields, defined by the users themselves, that need to be included. The list of these dynamic fields is fetched from the backend as a Promise of an Array of objects. The data loading itself is trivial, but we want to maintain visual symmetry by placing the first, third, fifth, etc., custom fields on the left, and the second, fourth, etc., on the right. Let's start with an imperative solution to see the pitfalls before adopting a reactive one.
@Component({
selector: 'app-example',
template: `
<div class="left-column">
<div>
<label>First Name</label>
<input />
</div>
<div>
<label>Age</label>
<input />
</div>
<div>
<label>Occupation</label>
<input />
</div>
<div *ngFor="let field of oddFields">
<label>{{ field.label }}</label>
<input />
</div>
</div>
<div class="right-column">
<div>
<label>Last Name</label>
<input />
</div>
<div>
<label>Interests</label>
<input />
</div>
<div>
<label>About yourself</label>
<input />
</div>
<div *ngFor="let field of evenFields">
<label>{{ field.label }}</label>
<input />
</div>
</div>
`,
styleUrls: [ './app.component.css' ]
})
export class ExampleComponent implements OnInit {
oddFields: Field[] = [];
evenFields: Field[] = [];
constructor(
private readonly customFieldsService: CustomFieldsService,
) {}
ngOnInit() {
this.customFieldsService.getCustomFields().then(fields => {
this.oddFields = fields.filter((_, index) => index % 2 === 0);
this.evenFields = fields.filter((_, index) => index % 2 === 0);
})
}
}
While this code achieves the desired outcome, it does so at a considerable cost:
- We are relying on a
Promise, which is not the preferred choice within the RxJS ecosystem. - We are adding more imperative logic to
ngOnInit. Overloading this lifecycle hook with custom logic is an anti-pattern, and it would be ideal to remove it entirely. - The logic within
ngOnInitis highly imperative, dictating a sequence of commands like "FETCH DATA!" and "SPLIT DATA!", rather than simply describing the final state of having divided data.
Now, let's see how a purely reactive approach elegantly resolves the same issue.
@Component({
selector: 'app-example',
template: `
<div class="left-column">
<div>
<label>First Name</label>
<input />
</div>
<div>
<label>Age</label>
<input />
</div>
<div>
<label>Occupation</label>
<input />
</div>
<div *ngFor="let field of (oddFields$ | async)">
<label>{{ field.label }}</label>
<input />
</div>
</div>
<div class="right-column">
<div>
<label>Last Name</label>
<input />
</div>
<div>
<label>Interests</label>
<input />
</div>
<div>
<label>About yourself</label>
<input />
</div>
<div *ngFor="let field of (evenFields$ | async)">
<label>{{ field.label }}</label>
<input />
</div>
</div>
`,
})
export class ExampleComponent {
customFields: [Observable<Field[]>, Observable<Field[]>] = partition(
from(this.customFieldsService.getCustomFields()).pipe(switchAll()),
(_, index) => index % 2 === 0,
);
oddFields$ = this.customFields[0].pipe(toArray());
evenFields$ = this.customFields[1].pipe(toArray());
constructor(
private readonly customFieldsService: CustomFieldsService,
) {}
}
Here is a step-by-step breakdown of our reactive strategy:
- We declare that all custom fields originate from a single stream, flattened using the
switchAlloperator. - We use the
partitionfunction to separate the stream into two: one for fields at odd indices (1, 3, 5...) for the left column, and another for even indices (2, 4, 6...) for the right column. - We then apply the
toArrayoperator to collect the values from each of these two streams into a single array. - These two resulting arrays are then displayed in the template.
Once more, we have removed a method and a significant amount of imperative code while enhancing readability. Anyone can now inspect these properties and immediately understand the source of the odd and even field arrays, without having to trace through a separate, verbose imperative function.
Practical Recommendations
While RxJS can dramatically improve Angular code, it is crucial to use it wisely to avoid introducing new problems. Here are some concise guidelines:
- Avoid
subscribe. Subscribing to a stream within a component often signals that imperative code has infiltrated your reactive logic. Use theasyncpipe instead. - Refrain from using
tap. Thetapoperator is designed for side effects, which are contrary to functional programming principles. It also tends to collect imperative code and breaks the otherwise clean flow of your RxJS operator chain. - Create custom RxJS operators. If you notice the same sequence of operators (for instance, a
filterwith a specific condition, followed by amapand finally atoArray) repeated throughout your codebase, consider extracting it into a single custom operator. You can think of a custom operator as a function that takes anObservableand returns a new, often transformed,Observable, typically by combining existing RxJS operators. - Keep your custom operators pure. When you do build your own operators, ensure they are pure functions. Avoid using
tapor introducing any side effects within them.
Final Thoughts
Initially, adopting a more reactive style for your Angular code might feel like overengineering. However, the payoff is immense. The first time you pinpoint and fix a bug within a minute, or you revisit code after six months and instantly grasp its logic, you will understand the immense maintainability benefits of a codebase free from imperative clutter.
This exploration of RxJS use cases in Angular will continue in future posts.
