RxJS

Building a safe autocomplete operator in RxJS

A well-known use case of RxJS is creating a simple autocomplete search in only a few lines of code. This article is not just about creating our own autocomplete operator, we will create an autocomplete operator that is completely safe (we will cover the perception of safe later in this article).

Building a safe autocomplete operator in RxJS — RxJS article by brechtbilliet on Angular In Depth
Building a safe autocomplete operator in RxJS — RxJS article by brechtbilliet on Angular In Depth
On this page · 4 sections

Putting together an autocomplete search with RxJS is a classic example that requires only a modest amount of code. In this piece, we go beyond that baseline: we build a custom autocomplete operator that is fully "safe," a concept we'll clarify as we progress.

For a typical autocomplete implementation, we'd reach for a debounceTime operator to limit how often we react to each keystroke, and we'd choose switchMap over mergeMap so that any in-flight request is cancelled the moment a new one comes in.

A standard version of that autocomplete could be written as follows:

const term$ = new BehaviorSubject('');
const results$ = term$
    .pipe(
        // wait until the user stops typing for a second
        debounceTime(1000),
        // higher order observable, abort previous
        // call if still busy
        switchMap(term => getAutocompleteSuggestions(term))
    )

The problem

Once the user pauses for a second, the browser dispatches a fresh XHR request. If the user resumes typing while that request is still pending and a newer one is about to start, the browser cancels the older request to prevent race conditions. This cancellation happens because switchMap unsubscribes from the previous observable.

That all sounds fine, except for one detail. What happens if the user starts typing while an XHR request is still active? With the debounceTime operator in place, the ongoing request won't be cancelled until a full second elapses. During that interval, new results can arrive and be displayed to the user—even though those results are stale and no longer relevant.

Our goal is to cancel the XHR request the instant the user types again, skipping the one-second wait entirely. That's a good reason to build a personalized operator. But before we write one from scratch, let's see if we can solve this by chaining existing operators.

One approach is to attach a takeUntil operator to the observable responsible for triggering the XHR call. Once takeUntil receives a value, it completes the source observable. Here, we want the observable to complete (and thus cancel the request) whenever the user types again. Based on that, we could attempt something like the following:

const term$ = new BehaviorSubject('');
const results$ = term$
    .pipe(
        debounceTime(1000),
        switchMap(term => 
            getAutocompleteSuggestions(term)
                .pipe(
                    takeUntil(term$) // this still won't work
                )
            )
        )
    )

Unfortunately, this approach doesn't work yet.

The term$ observable is set up as a BehaviorSubject for two distinct reasons:

  • It lets us provide an initial value to the subject
  • Internally, a BehaviorSubject behaves like a ReplaySubject(1), always retaining the most recent value. That's crucial if we need to subscribe to it later (which is exactly what happens with takeUntil).

Since term$ retains its latest value, the takeUntil operator always has something to emit, so every XHR request gets cancelled right away. That's not what we're after. Instead, we need to ignore the first emitted value from term$ on each occasion. The skip operator does exactly that, as shown in the example below:

const term$ = new BehaviorSubject('');
const results$ = term$
    .pipe(
        debounceTime(1000),
        switchMap(term => 
            getAutocompleteSuggestions(term)
                .pipe(
                    takeUntil(
                        //skip 1 value
                        term$.pipe(skip(1))
                    )
                        
                )
            )
        )
    )

Now the behavior we want is in place:

  • User enters 'l'
  • The application pauses for a second
  • It then issues an XHR request
  • User enters 'lu'
  • Even if the prior XHR request isn't done yet, it gets cancelled immediately (no more waiting that extra second)

As a result, the user never ends up with outdated information on screen.

Extracting the logic into a custom operator

Putting this logic inline each time isn't practical, so we'll package it into a dedicated operator.

Creating a custom operator turns out to be straightforward. In essence, an operator is a function that returns another function, and that inner function takes the source observable as its input.

const autocomplete = (/* additional parameters */) => 
    (source$) => source$.pipe(/* do stuff */ )

We accept time and selector as arguments, then compose them with the operators we've already discussed to form our own utility.

The resulting operator looks like this:

const autocomplete = (time, selector) => (source$) =>
  source$.pipe(
    debounceTime(time),
    switchMap((...args: any[]) => selector(...args)
        .pipe(
            takeUntil(
                source$
                    .pipe(
                        skip(1)
                    )
            )
        )
    )
  )

Integrating this operator into our app is quite simple:

const term$ = new BehaviorSubject('');
const results$ = term$
    .pipe(
        autocomplete(1000, term => getAutocompleteSuggestions(term))
    )

You can check out the full source code over on StackBlitz.

Conclusion

Going with just debounceTime and switchMap isn't a cure-all. Carelessly showing stale results to users is something we'd rather avoid, and crafting bespoke operators is surprisingly simple. Hope you found this read worthwhile.

Special thanks

A warm shout-out to the people who helped review this piece:

B
brechtbilliet

Writes about RxJS, Components, State. Active 2016–2022.

All 22 articles →