In my last project, our team needed to create a real time bid system where the user had the ability to bid for contracts for a given period of time. The remaining time for each contract would be displayed as a countdown, like this:
In this post I’ll show the different approaches we took to solve this challenge, and why the rxjs implementation was the most convenient in our case.
Solution #1: The Countdown Component ⏲️
We could have easily declared a countdown component with the expiration date as an input and run a setInterval that updates the remaining time every second.
@Component({
selector: 'app-countdown',
template: '<span>{{ remainingTime }}</span>',
})
export class CountdownComponent implements OnInit {
@Input() expireDate: Date;
public remainingTime: string;
ngOnInit(): void {
setInterval(() => {
this.remainingTime = this.calculateRemainingTime(this.expireDate);
}, 1000);
}
}
This implementation is simple and very easy to implement, but it has some disadvantages:
- Each countdown is independent from each other, which means they are not in sync as a group. Eventually, they start changing at different times which is not ideal in terms of user experience. (see why here: JS Event Loop)
- The parent component has no information about the status of each contract (expired or not). Therefore, an output in the countdown is necessary to provide the parent such information, so it can take corresponding action (remove/block from list, refresh list, etc).
- It’s not the most performant solution since we end up with a bunch of stateful components, each one of them with a timer, each timer performing the same task every second.
Solution #2: Master Timer (One timer to rule them all 🤴💍⚔️)
In order to address the issues from solution #1, we could have moved the logic for calculating the remaining time from the countdown component to the parent component.
The countdown component wouldn’t have any logic anymore, but it would simply display the provided value with some styles. It would now be a stateless component and could be renamed to time-display component.
@Component({
selector: 'app-time-display',
template: '<span>{{ time }}</span>',
})
export class TimeDisplayComponent {
@Input() time: string;
}
In the parent component, we can then use a single setInterval which would iterate over the list of contracts to update the corresponding remaining time, every second. This solves the synchronization issue.
@Component({
selector: 'app-root',
template: `
<div *ngFor="let contract of contracts">
<span>{{ contract.id }}</span>
<app-time-display [time]="contract.remainingTime"></app-time-display>
</div>
`,
})
export class AppComponent implements OnInit {
public contracts: Contract[] = MOCK_CONTRACTS;
ngOnInit(): void {
setInterval(() => {
this.contracts.forEach(contract => {
contract.remainingTime = this.calculateRemainingTime(contract.expiresAt);
});
}, 1000);
}
}
Now the parent component holds information about any contract that expires and can take the corresponding action for such a situation. In our example, we simply block the navigation to the detail of the contract and apply some styles to it.
At this point we solved all the issues from solution #1, but our parent component has more responsibilities now and some imperative code. We could use the power of rxjs to make it more declarative and reduce it’s responsibilities.
Solution #3: RxJS Timer Operator 🚀👨🚀
We will be using the rxjs timer
operator to transform this into a stream of data and provide it directly to our template with help of the async pipe.
Here the learnrxjs definition of timer operator.
After given duration, emit numbers in sequence every specified duration.
Additionally, we can see in the docs that the operator takes two arguments: initialDelay and period. That means, after initialDelay ms it emits the number 0, and then it emits this value increased by one every period ms. We can call this number the “tick” value and infer the response type as Observable<number>
.
We actually won’t be needing this “tick” value, but we use the tick event to make our “remaining time” calculations, like this:
import { Observable, timer } from 'rxjs';
@Component({
selector: 'app-root',
template: `
<div *ngFor="let contract of contracts$ | async">
<span>{{ contract.id }}</span>
<app-time-display [time]="contract.remainingTime"></app-time-display>
</div>
`,
})
export class AppComponent implements OnInit {
private contracts: Contract[] = MOCK_CONTRACTS;
public contracts$: Observable<Contract[]>;
ngOnInit(): void {
this.contracts$ = this.countdown();
}
private countdown(): Observable<Contract[]> {
return timer(0, 1000)
.pipe(
map(() => {
this.contracts.forEach(contract => {
contract.remainingTime = this.calculateRemainingTime(contract.expiresAt);
});
return this.contracts;
}),
);
}
}
We use the map
operator to both make our calculations and change the return type from Observable<number>
to Observable<Contract[]>
. We can now encapsulate the countdown function logic in a service to abstract it from the component, make it reusable (we use this feature in several screens) and consume the stream directly into the template.
export class AppComponent implements OnInit {
private contracts: Contract[] = MOCK_CONTRACTS;
public contracts$: Observable<Contract[]>;
constructor(private timerService: TimerService) { }
ngOnInit(): void {
this.contracts$ = this.timerService.countdown(this.contracts);
}
}
Conclusions
At the end, we achieved the following improvements with our final solution:
- Better performance and user experience
- Code readability and reusability
- It is rxjs friendly (easy integration with ngrx, socket.io, etc)
Acknowledgement
I would like to thank my teammates Kevin and Pablo for implementing these ideas, as well as Nicolás and Pablo Wolff for their feedback.
Demo 🧪
The full code for each solution can be found in the links below. There are extra lines there for clearing the intervals, completing the observable when necessary, and more.
Solution #1: The Countdown Component
Solution #2: Master Timer
Solution #3: RxJS Timer Operator
Top comments (1)
I didn't know about timer rxjs operator. Good post and comparisons.