Build a countdown timer using RxJS and Angular standalone components

Reading Time: 7 minutes

 48 total views

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.

let's go

Create a new Angular project

ng generate application day19-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, <app-timer> 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 { createButtonObservablesFn, 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" 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>
      <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;

  createTimerObservables = createButtonObservablesFn();
  timerInputSubscription = timerInputSubscriptionFn();

  ngAfterViewInit(): void {
    const timers$ = this.createTimerObservables(this.timers.map(({ nativeElement }) => nativeElement));
    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';

@Directive({
  selector: '[appTimerButton]',
  standalone: true
})
export class TimerButtonDirective {
  nativeElement!: HTMLButtonElement;

  constructor(el: ElementRef<HTMLButtonElement>) {
    this.nativeElement = el.nativeElement;
  }
}

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:

createTimerObservables = createButtonObservablesFn();
timerInputSubscription = timerInputSubscriptionFn();

Moreover, I wrote both functions in helper file to maintain small component file and good project structure.

// timer-controls.helper.ts

import { inject } from '@angular/core';
import { Observable, fromEvent, map, merge } from 'rxjs';
import { TimerService } from '../services/timer.service';
  
export const createButtonObservablesFn = () => {
    return (timers: HTMLButtonElement[]) => {
        return timers.map((nativeElement) => { 
            const totalSeconds = +(nativeElement.dataset['seconds'] || '0');
            return fromEvent(nativeElement, 'click').pipe(map(() => totalSeconds))
        });
    }
}

export const timerInputSubscriptionFn = () => {
    const timerService = inject(TimerService);
    return (observables: Observable<number>[]) => merge(...observables).subscribe((seconds) => {
      timerService.updateSeconds(seconds);
      console.log(`${seconds} seconds`);
    });
}

Explain createButtonObservableFn

createButtonObservablesFn is a high order function that returns a function. The function is ultimately executed in ngAfterViewInit to construct timer button observables.

  • const totalSeconds = +(nativeElement.dataset[‘seconds’] || ‘0’) – extract the value of data-seconds and convert to a number
  • fromEvent(nativeElement, ‘click’).pipe(map(() => totalSeconds)) – emit the specified seconds when user clicks button element

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.helpe.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:

  1. Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day29-countdown-timer-standalone
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day29-countdown-timer-standalone/
  3. Youtube playlist: https://www.youtube.com/watch?v=IzbMAiUJhS8&list=PLfiShkI8VaKj0YeJf-xr17dxR1S4_vPTO
  4. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30

Build a reactive countdown timer using RxJS and Angular

Reading Time: 6 minutes

 45 total views

Introduction

This is day 29 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to build a reactive countdown timer. The reactive countdown timer has the following functionalities:

  • a button toolbar to start a timer that has 20 seconds, 5 minutes, 15 minutes, 30 minutes or 60 minutes interval
  • A input field to enter arbitrary minutes
  • Display time left
  • Display the time when the timer stops

In this blog post, I describe how to merge button click and form submit streams to create a new stream to derive timer seconds. The new stream then emits the value to other streams to initiate count down and display timer stop time respectively. Ultimately, we build a reactive countdown timer that does not need a lot of codes to write.

let's go

Create a new Angular project

ng generate application day29-countdown-timer

Create Timer feature module

First, we create a Timer feature module and import it into AppModule. The feature module encapsulates TimerComponent, TimerControlsComponent and TimerPaneComponent.

Import TimerModule in AppModule

// timer.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TimerControlsComponent } from './timer-controls/timer-controls.component';
import { TimerPaneComponent } from './timer-pane/timer-pane.component';
import { TimerComponent } from './timer/timer.component';

@NgModule({
  declarations: [
    TimerComponent,
    TimerControlsComponent,
    TimerPaneComponent,
  ],
  imports: [
    CommonModule,
    FormsModule,
  ],
  exports: [
    TimerComponent
  ]
})
export class TimerModule { }

// app.module.ts

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    TimerModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare Timer components in feature module

In Timer feature module, we declare three Angular components, TimerComponent, TimerControlsComponent and TimerPaneComponent to build a reactive countdown timer.

src/app
├── app.component.ts
├── app.module.ts
└── timer
    ├── index.ts
    ├── services
    │   └── timer.service.ts
    ├── timer
    │   └── timer.component.ts
    ├── timer-controls
    │   └── timer-controls.component.ts
    ├── timer-pane
    │   └── timer-pane.component.ts
    └── timer.module.ts

TimerComponent acts like a shell that encloses TimerControlsComponent and TimerPaneComponent. For your information, <app-timer> is the tag of TimerComponent.

// timer.component.ts

import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-timer',
  template: `
  <div class="timer">
    <app-timer-controls></app-timer-controls>
    <app-timer-pane></app-timer-pane>
  </div>
  `,
  styles: [` ...omitted due to brevity... `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerComponent {}

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 { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { TimerService } from '../services/timer.service';

@Component({
  selector: 'app-timer-controls',
  template: `
  <div class="timer__controls">
    <button class="timer__button" #timer1>20 Secs</button>
    <button class="timer__button" #timer2>Work 5</button>
    <button class="timer__button" #timer3>Quick 15</button>
    <button class="timer__button" #timer4>Snack 20</button>
    <button class="timer__button" #timer5>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 OnInit, OnDestroy {

  @ViewChild('timer1', { static: true, read: ElementRef })
  timer1!: ElementRef<HTMLButtonElement>;

  @ViewChild('timer2', { static: true, read: ElementRef })
  timer2!: ElementRef<HTMLButtonElement>;

  @ViewChild('timer3', { static: true, read: ElementRef })
  timer3!: ElementRef<HTMLButtonElement>;

  @ViewChild('timer4', { static: true, read: ElementRef })
  timer4!: ElementRef<HTMLButtonElement>;

  @ViewChild('timer5', { static: true, read: ElementRef })
  timer5!: ElementRef<HTMLButtonElement>;

  @ViewChild('myForm', { static: true, read: ElementRef })
  myForm!: ElementRef<HTMLFormElement>;

  customMinutes = '';

  constructor(private timerService: TimerService) {}

  ngOnInit(): void {}

  ngOnDestroy(): void {}
}
// timer-pane.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { of } from 'rxjs';
import { TimerService } from '../services/timer.service';

@Component({
  selector: 'app-timer-pane',
  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 {

  oneSecond = 1000;
  
  displayEndTime$ = of('');

  displayTimeLeft$ = of('');

  constructor(private titleService: Title, private timerService: TimerService) {}
}

Next, I delete boilerplate codes in AppComponent and render TimerComponent in inline template.

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: '<app-timer></app-timer>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day 29 Countdown Timer';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Add timer service to share RxJS subjects and observables

In order to communicate data between TimerControlsComponent and TimerPaneComponent, I implement a TimerService to store Subjects and Observables that the components subscribe to stream events.

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);
  }
}

Use RxJS and Angular to implement timer control components

I am going to define Observables for button click and form submit events. Then, merge these observables to create a new observable to emit the selected seconds

Use ViewChild to obtain references to buttons and template-driven form

 @ViewChild('timer1', { static: true, read: ElementRef })
 timer1!: ElementRef<HTMLButtonElement>;

 @ViewChild('timer2', { static: true, read: ElementRef })
 timer2!: ElementRef<HTMLButtonElement>;

 @ViewChild('timer3', { static: true, read: ElementRef })
 timer3!: ElementRef<HTMLButtonElement>;

 @ViewChild('timer4', { static: true, read: ElementRef })
 timer4!: ElementRef<HTMLButtonElement>;

 @ViewChild('timer5', { static: true, read: ElementRef })
 timer5!: ElementRef<HTMLButtonElement>;

 @ViewChild('myForm', { static: true, read: ElementRef })
 myForm!: ElementRef<HTMLFormElement>;

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}

Create observables and emit value to secondsSub subject and subscribe in ngOnInit().

ngOnInit(): void {
    const videoNativeElement = this.video.nativeElement;
    const timer1$ = this.createButtonObservable(this.timer1.nativeElement, 20);
    const timer2$ = this.createButtonObservable(this.timer2.nativeElement, 300);
    const timer3$ = this.createButtonObservable(this.timer3.nativeElement, 900);
    const timer4$ = this.createButtonObservable(this.timer4.nativeElement, 1200);
    const timer5$ = this.createButtonObservable(this.timer5.nativeElement, 3600);

    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.add(
      merge(timer1$, timer2$, timer3$, timer4$, timer5$, myForm$)
        .subscribe((seconds) => this.timerService.updateSeconds(seconds))
    );
}

createButtonObservable(nativeElement: HTMLButtonElement, seconds: number) {
   return fromEvent(nativeElement, 'click').pipe(map(() => seconds))
}

myForm$ involves several steps in order to emit inputted seconds

  • filter(() => !!this.customMinutes) does nothing until input field has value
  • map(() => parseFloat(this.customMinutes)) converts value from string to number
  • map((customMinutes) => Math.floor(customMinutes * 60)) converts minutes to seconds
  • tap(() => this.myForm.nativeElement.reset()) resets template-driven form

Implement count down in TimerPaneComponent reactively

// timer-pane.component.ts

constructor(private titleService: Title, private timerService: TimerService) { }
  
oneSecond = 1000;
nowTo$ = this.timerService.seconds$.pipe(shareReplay(1));

countDown$ = this.nowTo$.pipe(
    switchMap((seconds) => timer(0, this.oneSecond).pipe(take(seconds + 1)))
);
displayTimeLeft$ = this.countDown$
   .pipe(
       withLatestFrom(this.nowTo$),
       map(([countdown, secondsLeft]) => secondsLeft - countdown),
       map((secondsLeft) => this.displayTimeLeft(secondsLeft)),
       tap((strTimeLeft) => this.titleService.setTitle(strTimeLeft))
    );

private displayTimeLeft(seconds: number) {
    const minutes = Math.floor(seconds / 60);
    const remainderSeconds = seconds % 60;
    return `${minutes}:${remainderSeconds < 10 ? '0' : '' }${remainderSeconds}`;
}

nowTo$ is an observable that emits the selected seconds. When I provide the selected 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, one second has elapsed and time left also decrements by 1 second

  • withLatestFrom(this.nowTo$) obtains the selected seconds
  • map(([countdown, secondsLeft]) => secondsLeft – countdown) derives the remaining seconds
  • map((secondsLeft) => this.displayTimeLeft(secondsLeft)) displays the remaining seconds in mm:ss format
  • tap((strTimeLeft) => this.titleService.setTitle(strTimeLeft)) updates the document title to display the remaining time

Therefore, displayTimeLeft$ is responsible to emit the remaining time in mm:ss format.

Display timer end time reactively

// timer-pane.component.ts

displayEndTime$ = this.nowTo$.pipe(map((seconds) => this.displayEndTime(Date.now(), seconds)));

private displayEndTime(now: number, seconds: number): string {
    const timestamp = now + seconds * this.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}`;
 }

displayEndTime$ is a trivial observable. It adds seconds to the current time to obtain the end time of the timer. Then, the Date object is formatted to hh:mm:ss AM/PM. Next, the observable is resolved in the inline template by async pipe.

The example is done and we have built a reactive countdown timer successfully.

Final Thoughts

In this post, I show how to use RxJS and Angular to build a reactive countdown timer. The first takeaway is to use switchMap and timer to create an observable to emit an integer per second to mimic count down. I achieve the effect declaratively without implementing any complex logic. The second takeaway is to encapsulate subject and observable in a shared service to exchange data between sibling components. The final takeaway is to use async pipe to resolve observable such that developers do not have to clean up subscriptions.

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:

  1. Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day29-countdown-timer
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day29-countdown-timer/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30