DEV Community

Connie Leung
Connie Leung

Posted on

Create type ahead search using RxJS and Angular standalone components

Introduction

This is day 6 of Wes Bos's JavaScript 30 challenge where I create a type ahead search box that filters out cities/states in the USA. In the tutorial, I created the components using RxJS, custom operators, Angular standalone components and removed the NgModules.

In this blog post, I define a function that injects HttpClient, retrieves USA cities from external JSON file and caches the response. Next, I create an observable that emits search input to filter out USA cities and states. Finally, I use async pipe to resolve the observable in the inline template to render the matching results.

Create a new Angular project

ng generate application day6-ng-type-ahead
Enter fullscreen mode Exit fullscreen mode

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 { TypeAheadComponent } from './type-ahead/type-ahead/type-ahead.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [
    TypeAheadComponent
  ],
  template: '<app-type-ahead></app-type-ahead>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day 6 NG Type Ahead';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}
Enter fullscreen mode Exit fullscreen mode

In Component decorator, I put standalone: true to convert AppComponent into a standalone component.

Instead of importing TypeAheadComponent in AppModule, I import TypeAheadComponent (that is also a standalone component) in the imports array because the inline template references it.

// main.ts

import { provideHttpClient } from '@angular/common/http';
import { enableProdMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, 
  {
    providers: [provideHttpClient()]
  })
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

provideHttpClient is a function that configures HttpClient to be available for injection.

Next, I delete AppModule because it is not used anymore.

Declare Type Ahead component

I declare standalone component, TypeAheadComponent, to create a component with search box. To verify the component is a standalone, standalone: true is specified in the Component decorator.

src/app
├── app.component.ts
└── type-ahead
    ├── custom-operators
    │   └── find-cities.operator.ts
    ├── interfaces
    │   └── city.interface.ts
    ├── pipes
    │   ├── highlight-suggestion.pipe.ts
    │   └── index.ts
    └── type-ahead
        ├── type-ahead.component.scss
        └── type-ahead.component.ts
Enter fullscreen mode Exit fullscreen mode

find-cities.ts is a custom RxJS operator that receives search value and filters out USA cities and states by it.

// type-ahead.component.ts

import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, OnInit, ViewChild, inject } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { Observable, shareReplay } from 'rxjs';
import { findCities } from '../custom-operators/find-cities.operator';
import { City } from '../interfaces/city.interface';
import { HighlightSuggestionPipe } from '../pipes/highlight-suggestion.pipe';

const getCities = () => {
  const httpService = inject(HttpClient);
  const endpoint = 'https://gist.githubusercontent.com/Miserlou/c5cd8364bf9b2420bb29/raw/2bf258763cdddd704f8ffd3ea9a3e81d25e2c6f6/cities.json';
  return httpService.get<City[]>(endpoint).pipe(shareReplay(1));
}

@Component({
  selector: 'app-type-ahead',
  standalone: true,
  imports: [
    HighlightSuggestionPipe,
    FormsModule,
    CommonModule,
  ],
  template: `
    <form class="search-form" #searchForm="ngForm">
      <input type="text" class="search" placeholder="City or State" [(ngModel)]="searchValue" name="searchValue">
      <ul class="suggestions" *ngIf="suggestions$ | async as suggestions">
        <ng-container *ngTemplateOutlet="suggestions?.length ? hasSuggestions : promptFilter; context: { suggestions, searchValue }"></ng-container>
      </ul>
    </form>

    <ng-template #promptFilter>
      <li>Filter for a city</li>
      <li>or a state</li>
    </ng-template>

    <ng-template #hasSuggestions let-suggestions="suggestions" let-searchValue="searchValue">
      <li *ngFor="let suggestion of suggestions">
        <span [innerHtml]="suggestion | highlightSuggestion:searchValue"></span>
        <span class="population">{{ suggestion.population | number }}</span>
      </li>
    </ng-template>
  `,
  styleUrls: ['./type-ahead.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TypeAheadComponent implements OnInit {

  @ViewChild('searchForm', { static: true })
  searchForm!: NgForm;

  searchValue = ''
  suggestions$!: Observable<City[]>;
  cities$ = getCities();

  ngOnInit(): void {
    this.suggestions$ = this.searchForm.form.valueChanges.pipe(findCities(this.cities$));
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeAheadComponent imports CommonModule, FormsModule and HighlightSuggestionPipe in the imports array. CommonModule is included to make ngIf and async pipe available in the inline template. After importing FormsModule, I can build a template form to accept search value. Finally, HighlightSuggestionPipe highlights the search value in the search results for aesthetic purpose.

cities$ is an observable that fetches USA cities from external JSON file. Angular 15 introduces inject that simplifies HTTP request logic in a function. Thus, I don’t need to inject HttpClient in the constructor and perform the same logic.

const getCities = () => {
  const httpService = inject(HttpClient);
  const endpoint = 'https://gist.githubusercontent.com/Miserlou/c5cd8364bf9b2420bb29/raw/2bf258763cdddd704f8ffd3ea9a3e81d25e2c6f6/cities.json';
  return httpService.get<City[]>(endpoint).pipe(shareReplay(1));
}

cities$ = getCities();
Enter fullscreen mode Exit fullscreen mode

suggestions$ is an observable that holds the matching cities and states after search value changes. It is subsequently resolved in inline template to render in a list.

Create RxJS custom operator

It is a matter of taste but I prefer to refactor RxJS operators into custom operators when observable has many lines of code. For suggestion$, I refactor the chain of operators into findCities custom operator and reuse it in TypeAheadComponent.

// find-cities.operator.ts

const findMatches = (formValue: { searchValue: string }, cities: City[]) => {
    const wordToMatch = formValue.searchValue;

    if (wordToMatch === '') {
        return [];
    }

    const regex = new RegExp(wordToMatch, 'gi');
    // here we need to figure out if the city or state matches what was searched
    return cities.filter(place => place.city.match(regex) || place.state.match(regex));
}

export const findCities = (cities$: Observable<City[]>) => {
    return (source: Observable<{ searchValue: string }>) =>
        source.pipe(
            skip(1),
            debounceTime(300),
            distinctUntilChanged(),
            withLatestFrom(cities$),
            map(([formValue, cities]) => findMatches(formValue, cities)),
            startWith([]),
        );
}
Enter fullscreen mode Exit fullscreen mode
  • skip(1) – The first valueChange emits undefined for unknown reason; therefore, skip is used to discard it
  • debounceTime(300) – emit search value after user stops typing for 300 milliseconds
  • distinctUntilChanged() – do nothing when search value is unchanged
  • withLatestFrom(cities$) – get the cities returned from HTTP request
  • map(([formValue, cities]) => findMatches(formValue, cities)) – call findMatches to filter cities and states by search value
  • startWith([]) – initially, the search result is an empty array

Finally, I use findCities to compose suggestion$ observable.

Use RxJS and Angular to implement observable in type ahead component

// type-ahead.component.ts

this.suggestions$ = this.searchForm.form.valueChanges
.pipe(findCities(this.cities$));
Enter fullscreen mode Exit fullscreen mode
  • this.searchForm.form.valueChanges – emit changes in template form
  • findCities(this.cities$) – apply custom operator to find matching cities and states

This is it, we have created a type ahead search that filters out USA cities and states by search value.

Final Thoughts

In this post, I show how to use RxJS and Angular standalone components to create type ahead search for filtering. The application has the following characteristics after using Angular 15's new features:

  • The application does not have NgModules and constructor boilerplate codes.
  • In main.ts, the providers array provides the HttpClient by invoking providerHttpClient function
  • In TypeAheadComponent, I inject HttpClient in a function to make http request and obtain the results. In construction phase, I assign the function to cities$ observable
  • Using inject to inject HttpClient offers flexibility in code organization. I can define getCities function in the component or move it to a utility file. Pre-Angular 15, HttpClient is usually injected in a service and the service has a method to make HTTP request and return the results

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:

Top comments (0)