RxJS Mastery – RxJS Patterns: a robust HTTP-based search input

How can we implement a robust search input field in JavaScript that triggers HTTP requests to get search results? This is a common problem reoccurring in many web applications. RxJS offers a variety of operators to declaratively define what happens in case of events. A keystroke in an input field is an event. That is why we can nicely define the operations that should be executed (or not) in the case of such events.

Aspects to consider for a search term input

In the most basic form, a keystroke in a search box triggers an HTTP request on what is currently written in the input field, and the result is displayed as it comes back from the server. With that approach, the following problems can occur:

  • ⚠ If for every keystroke a request is triggered, too many HTTP requests may be executed and overload our backend systems.
  • 😲 As distributed systems have varying response times, the request sequence A, B, and C can result for example in the response sequence B, C, and A. It could mean that the response to the request that was started first arrives last. This would confuse users.
  • 🔄 A search term can change in quick succession and theoretically also end up on the same value again. Imagine, “RxJS”, “RxJS pat”, followed by “RxJS”. In such cases, we may not want to execute the search for “RxJS” again because it was just executed some milliseconds ago.
  • ✅ Executing a search for any kind of search term, e.g. “R”, might be the wrong choice depending on the context. Starting the search after a certain word length can also here release some pressure from the backend systems and avoid unnecessary output to the user.
  • ◝ As asynchronous operations are involved, there might be a requirement to inform the user somehow about ongoing operations.
  • ⏳ In some cases the search could take a very long time and we would like to cancel it (automatically or by user interaction).
  • 🚫 Especially when working with distributed systems, errors are likely to occur.

We can address the problems above with the following potential solutions:

  • ⚠ Only after the user has stopped typing for some time, a request should be triggered. We need some sort of idle time detection after characters are entered.
  • 😲 There should only be one running search request at a time. This search request should correspond to what is currently written in the search field.
  • 🔄 When the input value doesn’t change compared to the last request, the request should not be executed.
  • ✅ The search should only start after at least 3 characters were entered.
  • ◝ A loading text should be shown underneath the search box during request execution.
  • ⏳ After 7s a search request should be cancelled automatically.
  • 🚫 Errors have to be caught and the user informed about them.

These are some of the decisive aspects in the context of such a search field. In the remainder of this article, we are going to tackle them one by one with RxJS.

How does RxJS address those aspects?

Basic setup and imperfect solution

Let us first have a look at the demo case. To nicely demo a search, I’m using the post API of jsonplaceholder. It supports a query parameter to filter by post title. This is perfect for our showcase. The search itself looks like this:

As said, a basic search could look like the code below this paragraph. It just uses the operators fromEvent, mergeMap, and ajax. From the keyup event of the search field, we’re accessing the target’s value. This value is the search term and it is passed as a query parameter to the API request. Ajax executes the API request, while mergeMap makes sure that the higher-order Observable is handled fine (ajax returns an Observable inside an Observable stream and therefore flatting is needed). Finally, in subscribe’s next handler, the response is accessed and put into some output container.

fromEvent(searchField, 'keyup')
    .pipe(
        mergeMap(event => ajax(`${apiUrl}?title_like=${event.target.value}`))
    )
    .subscribe({
        next: (httpEvent) => {
            const responseList = httpEvent.response;
            resultContainer.innerHTML = formatToHtml(responseList);
        }
    });

When typing we see a lot of requests flying through our network tab:

Network requests in the dev tools when we haven't optimised the search input yet with RxJS.

5 requests triggered although I did not type slowly!

To address the limitations, RxJS offers a good palette of operators. And they are easy to understand too! Let us now go through the collected list from above one by one and show how RxJS comes into play.

Only trigger requests after the user has stopped typing for some time

The first operator we are going to use to optimize our solution is debounceTime. This operator only emits after a certain time span has passed without another source emission.

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        mergeMap(event => ajax(`${apiUrl}?title_like=${event.target.value}`))
    )
    .subscribe({
        next: (httpEvent) => {
            const responseList = httpEvent.response;
            resultContainer.innerHTML = formatToHtml(responseList);
        }
    });

A time span of 150 ms is usually a good choice. But this is a trade-off in the end between waiting time and the number of requests. Anyway, it already improves the situation in our network tab:

Have only one running request at a time

As already mentioned above we need a higher-order mapping operator to handle the HTTP request. This is because ajax returns an Observable, but it would also be the case when we are using, e.g., Angular’s http implementation. RxJS offers the following higher-order mapping operators: concatMap, mergeMap, switchMap, and exhaustMap. Their behavior is described in this article.

The switch behavior always considers the newest inner Observable that was started and cancels older ones. This is perfect for our job. If a new input value from the search box arrives, a new inner Observable for the HTTP request is created. As an HTTP request is behind ajax’s Observable, the cancellation of it cancels the HTTP request.

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        switchMap(event => ajax(`${apiUrl}?title_like=${event.target.value}`))
    )
    .subscribe({
        next: (httpEvent) => {
            const responseList = httpEvent.response;
            resultContainer.innerHTML = formatToHtml(responseList);
        }
    });

So, do not be surprised to see canceled HTTP requests in the network tab (here we tried to provoke the situation by throttling the network and setting the “Slow 3G” in Google Chrome):

RxJS switchMap is cancelling ongoing requests if new ones are arriving.

In some rare cases, another mapping operator might make sense:

  • if requests are taking very long and we want them to be fully executed once started while still not overloading the server: exhaustMap. It will only run one request at a time.
  • every search should be triggered and executed in sequence because all results are interesting: concatMap.

Do not re-execute a request if the value for it has not changed

RxJS provides distinctUntilChanged that only emits if the new value is different from the last one that made it through. To make that work we need to apply this distinct logic to the search term itself and not the event coming from the input field. Because the event is always going to be distinct. Luckily also here RxJS supports us with pluck and filter. Pluck retrieves the value out of event.target while filter makes sure that only truthy values are arriving, i.e. no undefined is reaching that part of our Observable chain:

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        distinctUntilChanged(),
        switchMap(searchTerm => ajax(`${apiUrl}?title_like=${searchTerm}`))
    )
    .subscribe({
        next: (httpEvent) => {
            const responseList = httpEvent.response;
            resultContainer.innerHTML = formatToHtml(responseList);
        }
    });

The pluck also simplified the query parameter’s value and event.target.value is not needed anymore there. Instead of pluck, also map could have been an operator to access the target’s value. But pluck is a nice pick here because no null check is needed when traversing the object tree and we only need a single value.
As we are anyway already thinking about pluck, let us simplify the next handler by plucking the response field from the HTTP response:

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        distinctUntilChanged(),
        switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`)),
        pluck('response')
    )
    .subscribe({
        next: (response) => {
            resultContainer.innerHTML = formatToHtml(response);
        }
    });

Only start executing requests after the user has entered at least three characters

We have already seen the filter operator above and it will be used also in this case here. Furthermore, we would like to trim whitespaces because those are normally not interesting for a search. Hence trim is coming into play, combined with RxJS’s map.

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        map(value => value.trim()),
        filter(searchTerm => searchTerm.length > 2),
        distinctUntilChanged(),
        switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`)),
        pluck('response')
    )
    .subscribe({
        next: (response) => {
            resultContainer.innerHTML = formatToHtml(response);
        }
    });

Display a loading spinner

Here the debate begins. There exist many approaches to implement a loading spinner. Let us start with the following one:

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        map(value => value.trim()),
        filter(searchTerm => searchTerm.length > 2),
        distinctUntilChanged(),
        switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`).pipe(
            startWith({ response: { text: 'Loading...', type: 'LOADING_INDICATOR' }}),
        )),
        pluck('response')
    )
    .subscribe({
        next: (response) => {
            if (response?.type === 'LOADING_INDICATOR') {
                loadingIndicator.style.visibility = 'visible';
            } else {
                loadingIndicator.style.visibility = 'hidden';
                resultContainer.innerHTML = formatToHtml(response);
            }
        }
    });

In that first version, we are piping the Ajax request into startWith. To follow the structure for response we deliver the text Loading… in an object together with the type LOADING_INDICATOR. The pluck works and in the next handler, we can then put the result into the resultContainer and change the visibility of the loading indicator when required. This might be an acceptable solution and it is even fully declarative in the upper part (in the pipe after fromEvent). However, in the next handler, we are going into an imperative mode (anyway due to the resultContainer’s innerHTML handling, but that is usually addressed through frameworks, e.g. async pipe in Angular).

An alternative is to tap BehaviorSubjects from within the Observable chain. This variant is also somewhat imperative. But it gives us a second Observable to control the loading indicator:

const loadingSubject$ = new BehaviorSubject(false);
// some code to subscribe to the loadingSubject$ as Observable 
// and control the visibility of the loading element


tap(_ => loadingSubject$.next(true)),
switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`)),
tap(_ => loadingSubject$.next(false)),

Before and after the switchMap a tap operator is placed to perform the side-effect of telling the BehaviorSubject whether to show something or not.

Both variants have their drawbacks. I’m still looking for a good solution here.

Cancel a request automatically after 7 seconds

Last but not least we need to talk about the not-so-happy cases. Requests can time out or other errors can happen. We would like to inform our users about such cases. Therefore after 7 seconds of no response, an error message should be displayed and the ongoing request be cancelled. Yet another specific operator comes to the rescue. This time it is timeout.

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        map(value => value.trim()),
        filter(searchTerm => searchTerm.length > 2),
        distinctUntilChanged(),
        tap(_ => loadingSubject$.next(true)),
        switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`).pipe(
            timeout(REQUEST_TIMEOUT))
        ),
        tap(_ => loadingSubject$.next(false)),
        pluck('response')
    )
    .subscribe({
        next: (response) => {
            resultContainer.innerHTML = formatToHtml(response);
        }
    });

The timeout operator is defined in the pipe after ajax. This is because we want to timeout if the request is too slow and we would like the request to be canceled in such cases. As a consequence, we can see the following in our browser when the response does not arrive in time (REQUEST_TIMEOUT is the timeout in milliseconds).

Let us optimize that in the next section where we catch uncaught things.

Show an error message when the search request fails

If we would like to catch any errors, you might have already guessed the operator for that. We apply catchError:

fromEvent(searchField, 'keyup')
    .pipe(
        debounceTime(150),
        filter(Boolean),
        pluck('target', 'value'),
        map(value => value.trim()),
        filter(searchTerm => searchTerm.length > 2),
        distinctUntilChanged(),
        tap(_ => loadingSubject$.next(true)),
        switchMap(searchTerm => ajax<Post[]>(`${apiUrl}?title_like=${searchTerm}`).pipe(
            timeout(REQUEST_TIMEOUT))
        ),
        tap(_ => loadingSubject$.next(false)),
        pluck('response'),
        catchError(error => {
            loadingSubject$.next(false);
            displayError(error.message);
            return [];
        }),
    )
    .subscribe({
        next: (response) => {
            resultContainer.innerHTML = formatToHtml(response);
        }
    });

The nice thing about the catch is that we can place it at the end and it will handle any error that happens in the stream. The default value empty array is returned. With a little bit of CSS and HTML, it looks like in the image below. The error is shown while the loading indicator is hidden again.

Conclusion

In the course of this article, we have used 10 different operators. And I could even think of some more to apply in that context, e.g. retry or finalize. This is probably where some people start to complain about how hard to learn RxJS is. But, please keep in mind that every operator is designed for its purpose and does its specific task well! This allows us to combine different operators as we wish, to specify the behavior of our program from simple building blocks. The nice thing about all that: you can declaratively program and define what should happen.

Please note, that the TypeScript usage in these examples was not ideal. More type safety should be added. But the focus here was on RxJS. If someone has a good approach for the loading indicator, let me know!

This post is part of the RxJS mastery series. As always the code examples can be found on GitHub.