Build a countdown timer using RxJS and Angular standalone components
Connie Leung
Posted on March 7, 2023
Introduction
This is day 29 of Wes Bos's JavaScript 30 challenge where I build a countdown timer using RxJS, standalone components and standalone directive in Angular 15.
Create a new Angular project
ng generate application day29-countdown-timer-standalone
Bootstrap AppComponent
First, I convert AppComponent into standalone component such that I can bootstrap AppComponent and inject providers in main.ts.
// app.component.ts
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TimerComponent } from './timer/timer/timer.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [
TimerComponent
],
template: '<app-timer></app-timer>',
styles: [`
:host {
display: block;
}
`]
})
export class AppComponent {
title = 'Day 29 Standalone Countdown Timer';
constructor(titleService: Title) {
titleService.setTitle(this.title);
}
}
In Component decorator, I put standalone: true
to convert AppComponent into a standalone component.
Instead of importing TimerComponent
in AppModule, I import TimerComponent
(that is also a standalone component) in the imports array because the inline template references it.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch(err => console.error(err));
Next, I can delete AppModule that is now useless.
Declare Timers components to build a countdown timer
I declare standalone components, TimerComponent
, TimerControlsComponent
and TimerPaneComponent
to build a reactive countdown timer. To verify the components are standalone, standalone: true
is specified in the Component decorator.
src/app
├── app.component.ts
└── timer
├── directive
│ └── timer-button.directive.ts
├── helpers
│ ├── timer-controls.helper.ts
│ └── timer-pane.helper.ts
├── services
│ └── timer.service.ts
├── timer
│ └── timer.component.ts
├── timer-controls
│ └── timer-controls.component.ts
└── timer-pane
└── timer-pane.component.ts
TimerComponent
acts like a shell that encloses TimerControlsComponent
and TimerPaneComponent
. For your information, is the tag of TimerComponent
.
// timer.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { TimerControlsComponent } from '../timer-controls/timer-controls.component';
import { TimerPaneComponent } from '../timer-pane/timer-pane.component';
@Component({
selector: 'app-timer',
standalone: true,
imports: [
TimerControlsComponent,
TimerPaneComponent
],
template: `
<div class="timer">
<app-timer-controls></app-timer-controls>
<app-timer-pane></app-timer-pane>
</div>
`,
styles: [`
:host {
display: block;
}
.timer {
display: flex;
min-height: 100vh;
flex-direction: column;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerComponent {}
TimerComponent
imports TimerControlsComponent
and TimerPaneComponent
in the imports array. TimerControlsComponent
is included such that I can use <app-timer-controls> in the inline template. Similarly, TimerPaneComponent
provides <app-timer-pane> selector that is also seen in the inline template.
TimerControlsComponent
encapsulates buttons and input field to emit selected seconds whereas TimePaneComponent
subscribes to the emitted value to initiate count down and render time left.
// timer-controls.component.ts
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnDestroy, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subscription, filter, fromEvent, map, tap } from 'rxjs';
import { TimerButtonDirective } from '../directive/timer-button.directive';
import { timerInputSubscriptionFn } from '../helpers/timer-controls.helper';
@Component({
selector: 'app-timer-controls',
standalone: true,
imports: [
FormsModule,
TimerButtonDirective,
],
template: `
<div class="timer__controls">
<button class="timer__button" data-seconds="20">20 Secs</button>
<button class="timer__button" data-seconds="300">Work 5</button>
<button class="timer__button" data-seconds="900">Quick 15</button>
<button class="timer__button" data-seconds="1200">Snack 20</button>
<button class="timer__button" data-seconds="3600">Lunch Break</button>
<form name="customForm" id="custom" #myForm="ngForm">
<input type="text" name="minutes" placeholder="Enter Minutes" [(ngModel)]="customMinutes">
</form>
</div>
`,
styles: [`...omitted due to brevity...`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerControlsComponent implements OnDestroy, AfterViewInit {
@ViewChild('myForm', { static: true, read: ElementRef })
myForm!: ElementRef<HTMLFormElement>;
@ViewChildren(TimerButtonDirective)
timers!: QueryList<ElementRef<HTMLButtonElement>>;
customMinutes = '';
subscriptions!: Subscription;
timerInputSubscription = timerInputSubscriptionFn();
ngAfterViewInit(): void {
const timers$ = this.timers.map((timer) => timer.click$);
const myForm$ = fromEvent(this.myForm.nativeElement, 'submit')
.pipe(
filter(() => !!this.customMinutes),
map(() => parseFloat(this.customMinutes)),
map((customMinutes) => Math.floor(customMinutes * 60)),
tap(() => this.myForm.nativeElement.reset())
);
this.subscriptions = this.timerInputSubscription([...timers$, myForm$]);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
}
TimerControlsComponent
is consisted of a template form and a list of buttons; therefore, I import FormsModule
and a standalone directive, TimerButtonDirective
. I define the directive in order to query the button elements with ViewChildren
decorator.
// timer-button.directive.ts
import { Directive, ElementRef } from '@angular/core';
import { Observable, fromEvent, map } from 'rxjs';
@Directive({
selector: '[appTimerButton]',
standalone: true
})
export class TimerButtonDirective {
click$!: Observable<number>;
constructor(el: ElementRef<HTMLButtonElement>) {
const totalSeconds = +(el.nativeElement.dataset['seconds'] || '0');
this.click$ = fromEvent(el.nativeElement, 'click').pipe(map(() => totalSeconds));
}
}
Next, I add appTimerButton
to the button elements in the inline template
// timer-controls.component.ts
<button class="timer__button" data-seconds="20" appTimerButton>20 Secs</button>
<button class="timer__button" data-seconds="300" appTimerButton>Work 5</button>
<button class="timer__button" data-seconds="900" appTimerButton>Quick 15</button>
<button class="timer__button" data-seconds="1200" appTimerButton>Snack 20</button>
<button class="timer__button" data-seconds="3600" appTimerButton>Lunch Break</button>
// timer-pane.components
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { displayEndTimeFn, displayTimeLeftFn, nowToFn } from '../helpers/timer-pane.helper';
@Component({
selector: 'app-timer-pane',
standalone: true,
imports: [AsyncPipe],
template: `
<div class="display">
<h1 class="display__time-left">{{ displayTimeLeft$ | async }}</h1>
<p class="display__end-time">{{ displayEndTime$ | async }}</p>
</div>
`,
styles: [`...omitted due to brevity...`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerPaneComponent {
nowTo$ = nowToFn();
displayEndTime$ = displayEndTimeFn(this.nowTo$);
displayTimeLeft$ = displayTimeLeftFn(this.nowTo$);
}
I declare all the components that require to build the countdown timer. Next section is going to describe TimerService that shares specified seconds between components.
Add timer service to share RxJS subjects and observables
TimerService
stores Subjects and Observables that the components subscribe to receive specified seconds.
// timer.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class TimerService {
private readonly secondsSub = new Subject<number>();
readonly seconds$ = this.secondsSub.asObservable();
updateSeconds(seconds: number) {
this.secondsSub.next(seconds);
}
}
Demystifies RxJS logic in TimerControlsComponent
TimerControlsComponent
encapsulates all RxJS observables and subscription in the following lines:
timerInputSubscription = timerInputSubscriptionFn();
Moreover, I wrote the function in helper file to maintain small component file and good project structure.
// timer-controls.helper.ts
import { inject } from '@angular/core';
import { Observable, merge } from 'rxjs';
import { TimerService } from '../services/timer.service';
export const timerInputSubscriptionFn = () => {
const timerService = inject(TimerService);
return (observables: Observable<number>[]) => merge(...observables).subscribe((seconds) => timerService.updateSeconds(seconds));
}
Explain timerInputSubscriptionFn
timerInputSubscriptionFn
is also a high order function that returns a function. The function returns a subscription that updates the Subject in TimerService.
- inject(TimerService) – inject TimerService in function instead of constructor
- merge(…observables) – merge all observables to create a new observable that emits second
- subscribe((seconds) => { timerService.updateSeconds(seconds); }) – subscribe observable and update TimerService with the selected seconds
Demystifies RxJS logic in TimerPaneComponent
TimerPaneComponent
class is so succinct that you wonder where the Observable codes go. They are extracted to functions in helper file
// timer-pane.helper.ts
import { inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { shareReplay, Observable, map, switchMap, timer, take, withLatestFrom, tap } from 'rxjs';
import { TimerService } from '../services/timer.service';
const oneSecond = 1000;
export const nowToFn = () => {
const timerService = inject(TimerService);
return timerService.seconds$.pipe(shareReplay(1));
}
const displayEndTime = (now: number, seconds: number): string => {
const timestamp = now + seconds * oneSecond;
const end = new Date(timestamp);
const hour = end.getHours();
const amPm = hour >= 12 ? 'PM': 'AM';
const adjustedHour = hour > 12 ? hour - 12 : hour;
const minutes = end.getMinutes();
return `Be Back At ${adjustedHour}:${minutes < 10 ? '0' : ''}${minutes} ${amPm}`;
}
export const displayEndTimeFn = (nowTo$: Observable<number>) =>
nowTo$.pipe(map((seconds) => displayEndTime(Date.now(), seconds)));
const displayTimeLeft = (seconds: number) => {
const numSeconds = 60;
const minutes = Math.floor(seconds / numSeconds);
const remainderSeconds = seconds % numSeconds;
return `${minutes}:${remainderSeconds < 10 ? '0' : '' }${remainderSeconds}`;
}
export const displayTimeLeftFn = (nowTo$: Observable<number>) => {
const titleService = inject(Title);
const countDown$ = nowTo$.pipe(switchMap((seconds) =>
timer(0, oneSecond).pipe(take(seconds + 1))
));
return countDown$
.pipe(
withLatestFrom(nowTo$),
map(([countdown, secondsLeft]) => secondsLeft - countdown),
map((secondsLeft) => displayTimeLeft(secondsLeft)),
tap((strTimeLeft) => titleService.setTitle(strTimeLeft))
);
}
nowTo
function observes timeService.second$
, caches the value with shareReplay and return the Observable.
displayEndTimeFn
is a function that returns the end time of the the timer in an Observable. displayEndTime
function performs DateTime manipulation and prints the result in a message.
displayTimeLeftFn
simulates the count down effect reactively.
const countDown$ = nowTo$.pipe(switchMap((seconds) =>
timer(0, oneSecond).pipe(take(seconds + 1))
));
When nowTo$
emits seconds, (let's say N), I have to cancel the previous timer and create a new timer that emits (N + 1) values (0, 1, 2, ….N). Therefore, I use switchMap to return a timer observable
When countDown$
emits a value, 1 second has elapsed and time remained should also decrement.
- withLatestFrom(nowTo$) obtains the selected seconds
- map(([countdown, secondsLeft]) => secondsLeft – countdown) derives the remaining seconds
- map((secondsLeft) => displayTimeLeft(secondsLeft)) displays the remaining seconds in mm:ss format
- tap((strTimeLeft) => titleService.setTitle(strTimeLeft)) updates the document title to display the remaining time
This is it and I have built a reactive countdown timer using standalone components and directive only.
Final Thoughts
In this post, I show how to use RxJS, Angular standalone components and directive to build a countdown timer. The application has the following characteristics after using Angular 15’s new features:
- The application does not have NgModules and constructor boilerplate codes.
- The standalone components are very clean because I moved as many RxJS codes and dependency injections to separate helper files as possible.
- Using inject offers flexibility in code organization. Initially, I constructed Observables functions outside of component class and later moved those functions to helper files. Pre-Angular 15, TimeService and Title must inject in constructor and I implement additional statements and methods inside the component to access them.
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day29-countdown-timer-standalone
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day29-countdown-timer-standalone/
- Youtube playlist: https://www.youtube.com/watch?v=IzbMAiUJhS8&list=PLfiShkI8VaKj0YeJf-xr17dxR1S4_vPTO
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30
Posted on March 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.