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())
);
Getting Started
To kick things off, make sure RxJS is installed first.
npm install rxjs
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>;
}
Typeahead Operator
export function typeahead<Out>(
options: ITypeaheadOperatorOptions<Out>
): OperatorFunction<string, Out> {
return source => {
return source.pipe(
...operators
// The implementation goes here
);
};
}
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))
);
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];
})
);
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:
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.
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
concatoperator 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,
switchMapnever processes it, so it fails to abort the ongoing request. - The user then reverts to "Angular", but the standard behavior of
distinctUntilChangedprevents 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
})
)
)
)
)
);
Let's walk through the logic (refer to the numbered comments in the code):
-
shouldAllowSameValueacts as a control flag for thedistinctUntilChangedoperator, permitting identical values to pass when needed. Its initial state isfalse. -
When the user submits an invalid search term prior to the completion of an ongoing request,
shouldAllowSameValuegets flipped totrue. - If
shouldAllowSameValueevaluates totrue, 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 thedistinctUntilChangedoperator. This is how
distinctUntilChangedtypically operates: it outputs a value solely when it differs from its predecessor.Turns the
loadFnfunction 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;
})
)
)
)
)
);
};
}
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("");
});
<input type="text" id="search-input" />
<ul id="results-container"></ul>
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}`
);
},
})
);
}
}
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.
