Ever find yourself typing into a search field and suddenly seeing suggestions pop up? That's a typeahead. It's an excellent approach to assist users in locating what they need. Throughout this piece, you'll discover the process of creating a typeahead component with RxJS.

Problem

Looking at it from the user's side, their need is straightforward: they wish to search and have results appear instantly while they're typing. On the developer's side, the challenge involves refining search logic to avoid overwhelming the server with an excessive number of requests.

Solution

Striking a balance between a smooth user experience and system performance requires limiting request frequency. You can achieve this with RxJS by applying a debounce to the user's input, triggering a request only after the user pauses typing for a specified duration.

Here's a look at its final usage:

const search$ = fromEvent(searchInputEl, "input").pipe(
  map(event => event.target.value),
  typeahead({
    minLength: 3,
    debounceTime: 250,
    loadFn: searchTerm => {
      const searchQuery = searchTerm ? `?title_like=^${searchTerm}` : "";
      return fetch(`https://jsonplaceholder.typicode.com/posts${searchTerm}`);
    },
  }),
  // convert the response to json
  switchMap(response => response.json())
);
Enter fullscreen mode Exit fullscreen mode

Getting Started

To kick things off, make sure RxJS is installed first.

npm install rxjs
Enter fullscreen mode Exit fullscreen mode

Note: I’m opting for TypeScript mainly because it clarifies the available type options. Skipping them is fine, but if typing is your goal, I’d recommend choosing a framework with built-in TypeScript support.

The Typeahead Operator

Your job is to design a custom operator that accepts an object containing these properties:

Interface for Options

interface ITypeaheadOperatorOptions<Out> {
  /**
   * The minimum length of the allowed search term.
   */
  minLength: number;
  /**
   * The amount of time between key presses before making a request.
   */
  debounceTime: number;
  /**
   * Whether to allow empty string to be treated as a valid search term.
   * Useful for when you want to show defaul results when the user clears the search box
   *
   * @default true
   */
  allowEmptyString?: boolean;

  /**
   * The function that will be called to load the results.
   */
  loadFn: (searchTerm: string) => ObservableInput<Out>;
}
Enter fullscreen mode Exit fullscreen mode

Typeahead Operator

export function typeahead<Out>(
  options: ITypeaheadOperatorOptions<Out>
): OperatorFunction<string, Out> {
  return source => {
    return source.pipe(
      ...operators
      // The implementation goes here
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

The typeahead custom operator takes a config object as its input and returns an operator function. That function receives an observable of the search term and yields an observable of results.

Those results are typed using the generic Out, which corresponds to whatever type the loadFn function returns.

Scenario 1: Typical Typeahead

Note: A search term is considered valid if it has remained unchanged for a specified period (debounceTime) and already meets the minimum character requirement, such as three or more characters.

  • A request is dispatched when the user enters a valid search term.
  • If a new valid search term is entered while the previous request is still pending, that pending request gets aborted, and another request is initiated using the latest term.
  • When the user types a valid term but then returns to the prior term before the debounce interval elapses, no request gets made.
return source.pipe(
  debounceTime(options.debounceTime),
  filter(value => typeof value === "string"),
  filter(value => {
    if (value === "") {
      return options.allowEmptyString ?? true;
    }
    return value.length >= options.minLength;
  }),
  distinctUntilChanged(),
  switchMap(searchTerm => options.loadFn(searchTerm))
);
Enter fullscreen mode Exit fullscreen mode

debounceTime: Imagine a moving time frame. It holds off on emitting the most recent value until a set delay has passed. If another value arrives within that delay, the clock resets and the waiting starts anew. This helps avoid firing a request on every single keystroke.

filter: The first filter ensures only string values get through—it may seem redundant, but it’s required since debounceTime can emit null when the source observable finishes. The second filter allows only values that meet the minimum length or are an empty string (if that’s permitted).

Note: For default results to appear when the search box is cleared or when the page/dropdown opens for the first time, you’ll need to treat the empty string as a valid query.

distinctUntilChanged: It emits only when the incoming value differs from the prior one (by default). This blocks redundant requests using the same search term. For instance, after the user types "rxjs" and results load, typing "rxjs" again won't trigger another fetch.

switchMap: It terminates the ongoing observable and subscribes to the newest one. When a user enters a new search term before the current request finishes, that request gets aborted and replaced with a fresh one.

Suppose the user types "Typeahead" and then adds "Operator" while the first request is still pending—the original request is dropped, and a new one goes out for "Typeahead Operator". This only happens if the initial request exceeds the debounce duration.

Scenario 2: Cache Results

In many practical cases, especially when data doesn’t change often, caching can be advantageous.

To achieve this, store the in-flight observables using the shareReplay operator.

const cache: Record<string, Observable<Out>> = {};
return source.pipe(
  // ... same operators
  switchMap(searchTerm => {
    // Initialize Observable in cache if it doesn't exist
    if (!cache[searchTerm]) {
      cache[searchTerm] = options.loadFn(searchTerm).pipe(
        shareReplay({
          bufferSize: 1,
          refCount: false,
          windowTime: 5000,
        })
      );
    }

    // Return the cached observable
    return cache[searchTerm];
  })
);
Enter fullscreen mode Exit fullscreen mode

shareReplay: If the source observable tied to the current search term is already cached, that observable is returned directly; otherwise, the observable is created, executed, and stored in the cache for later reuse.

bufferSize: Assuming the underlying data comes from an HTTP request, this value corresponds to a single response.

windowTime: This determines that any cached observable gets purged from the cache after a 5-second interval.

A couple of other optimization strategies come to mind:

  1. You could instantly supply the cached observable as soon as the user enters the search term, bypassing the debounce delay. I've intentionally avoided this to maintain a uniform user experience—my setup applies a 1.5s debounce.

  2. Drawing from the stale-while-revalidate approach, you can serve the cached observable immediately while quietly triggering a fresh request to refresh the cache. The concat operator is well-suited for this pattern.

Scenario 3: The Edge Case

The previous solution generally covers most situations, but there's a specific edge scenario worth addressing.

  • A legitimate search term (e.g., "Angular") is entered, triggering an HTTP request.
  • Before that request completes, the user switches to a term that yields no results (e.g., "Ng"). Because this term falls short of the filtering criteria, switchMap never processes it, so it fails to abort the ongoing request.
  • The user then reverts to "Angular", but the standard behavior of distinctUntilChanged prevents this input from being emitted again.
let shouldAllowSameValue = false; // -> 1
return source.pipe(
  distinctUntilChanged((prev, current) => {
    if (shouldAllowSameValue /** -> 3 */) {
      shouldAllowSameValue = false;
      return false;
    }
    return prev === current; // -> 4
  }),
  switchMap(searchTerm =>
    // -> 5
    from(options.loadFn(searchTerm)).pipe(
      takeUntil(
        source.pipe(
          tap(() => {
            shouldAllowSameValue = true; // -> 2
          })
        )
      )
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

Let's walk through the logic (refer to the numbered comments in the code):

  1. shouldAllowSameValue acts as a control flag for the distinctUntilChanged operator, permitting identical values to pass when needed. Its initial state is false.
  2. When the user submits an invalid search term prior to the completion of an ongoing request, shouldAllowSameValue gets flipped to true.
  3. If shouldAllowSameValue evaluates to true, this signals that an invalid search term was entered before the previous request had finished. In such a scenario, we allow the same value to bypass the distinctUntilChanged operator.
  4. This is how distinctUntilChanged typically operates: it outputs a value solely when it differs from its predecessor.

  5. Turns the loadFn function into an observable, initiates a subscription, and aborts it if the source observable dispatches a new value while the request remains pending.

Regarding distinctUntilChanged: its standard action is to emit only when a value is not the same as the prior one. This prevents redundant requests for identical search terms—like when a user enters "hello," sees results, then types "hello" again; we avoid re-fetching.

As for switchMap: it terminates the existing observable and subscribes to the new one. If a user enters a new search term before the current request completes, it halts the ongoing request and launches a fresh one.

Full implementation

export function typeahead<Out>(
  options: ITypeaheadOperatorOptions<Out>
): OperatorFunction<string, Out> {
  let shouldAllowSameValue = false;
  return source => {
    return source.pipe(
      debounceTime(options.debounceTime),
      filter(value => typeof value === "string"),
      filter(value => {
        if (value === "") {
          return options.allowEmptyString ?? true;
        }
        return value.length >= options.minLength;
      }),
      distinctUntilChanged((prev, current) => {
        if (shouldAllowSameValue) {
          shouldAllowSameValue = false;
          return false;
        }
        return prev === current;
      }),
      switchMap(searchTerm =>
        from(options.loadFn(searchTerm)).pipe(
          takeUntil(
            source.pipe(
              tap(() => {
                shouldAllowSameValue = true;
              })
            )
          )
        )
      )
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

Example

Framework Agnostic Example

import { fromEvent } from "rxjs";
import {
  debounceTime,
  distinctUntilChanged,
  filter,
  switchMap,
} from "rxjs/operators";

const searchInputEl = document.getElementById("search-input");
const resultsContainerEl = document.getElementById("results-container");

const search$ = fromEvent(searchInputEl, "input").pipe(
  map(event => searchInputEl.value),
  typeahead({
    minLength: 3,
    debounceTime: 1000,
    loadFn: searchTerm => {
      const searchQuery = searchTerm ? `?title_like=^${searchTerm}` : "";
      return fetch(`https://jsonplaceholder.typicode.com/posts${searchTerm}`);
    },
  }),
  // convert the response to json
  switchMap(response => response.json())
);

search$.subscribe(results => {
  resultsContainerEl.innerHTML = results
    .map(result => `<li>${result.title}</li>`)
    .join("");
});
Enter fullscreen mode Exit fullscreen mode
<input type="text" id="search-input" />
<ul id="results-container"></ul>
Enter fullscreen mode Exit fullscreen mode

It is important to remember that the convention in this situation is to rely on switchMap, rather than other flattening operators such as mergeMap or concatMap.

Note: For brevity, I have intentionally omitted error handling here; in practice, you may wish to add retry logic or present an error notification to the user.

Angular Example

import { Component } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { FormControl } from "@angular/forms";
import { Observable } from "rxjs";
import {
  debounceTime,
  distinctUntilChanged,
  filter,
  switchMap,
} from "rxjs/operators";

@Component({
  selector: "app-search-bar",
  template: `
    <input type="text" [formControl]="searchControl" />
    <ul>
      <li *ngFor="let result of results$ | async">{{ result.title }}</li>
    </ul>
  `,
})
export class SearchBarComponent {
  searchControl = new FormControl();
  results$: Observable<any[]>;

  constructor(private http: HttpClient) {
    this.results$ = this.searchControl.valueChanges.pipe(
      typeahead({
        minLength: 3,
        debounceTime: 300,
        loadFn: searchTerm => {
          const searchQuery = searchTerm ? `?title_like=^${searchTerm}` : "";
          return this.#http.get<any[]>(
            `https://jsonplaceholder.typicode.com/posts${searchQuery}`
          );
        },
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Backpressure

In plain terms, backpressure refers to the strain caused by an overwhelming influx of data that our system isn't equipped to process simultaneously. Picture a factory conveyor belt set at high speed—the workers simply can't match its pace with the products arriving.

For a typeahead feature, allowing each and every keystroke from all users to trigger a server query is a recipe for disaster—our servers would be overloaded before a JavaScript framework goes out of fashion. Put technically, this swift surge of data might form a bottleneck, resulting in higher latency and greater resource usage.

Typically, this isn't a primary worry for frontend developers, since their attention leans toward user experience rather than backend capacity. Still, it's crucial to recognize that frontend decisions—such as determining the frequency of server calls—can significantly influence how the backend performs.

Conclusion

Throughout this piece, we've walked through creating a typeahead component with the help of RxJS. Additionally, we've covered backpressure and its potential effects on performance. Our hope is that you found this guide useful and that it aids you in crafting more robust applications going forward.

For extra enhancements, you could pair this setup with Infinite Scroll to deliver a distinctive user experience.