Interview question
Build a cancellable Angular typeahead that cannot show stale results
Builds a debounced typeahead with latest-request semantics, local error recovery, explicit states, and lifecycle-safe subscription ownership.
TL;DR
Builds a debounced typeahead with latest-request semantics, local error recovery, explicit states, and lifecycle-safe subscription ownership.
RxJS debounce, distinctUntilChanged, switchMap, HttpClient cancellation, error placement, signals, and stale-response races.
Practice the problem like a real interview: restate, reason, implement, and test.
Implement a standalone search component backed by SearchApi.search(query). Normalize input, wait 250 ms, ignore repeats, and cancel the previous request when a new valid query arrives. Expose idle, loading, success, empty, and error states. A failed request must not terminate future searches, and destroying the component must stop all work.
type "ang" -> request A starts
type "angular" before A completes -> A is unsubscribed; request B owns the UI
B returns [] -> empty state
next query fails -> error state; a later query still works
switchMap so a new query unsubscribes the previous HttpClient request.import {Component, inject} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {Subject, catchError, concat, debounceTime, distinctUntilChanged, map, of, switchMap} from 'rxjs';
type Result = {id: string; label: string};
type State =
| {kind: 'idle'}
| {kind: 'loading'}
| {kind: 'ready'; results: readonly Result[]}
| {kind: 'error'; message: string};
@Component({selector: 'app-typeahead', templateUrl: './typeahead.html'})
export class Typeahead {
private readonly api = inject(SearchApi);
private readonly queryChanges = new Subject<string>();
readonly state = toSignal(
this.queryChanges.pipe(
map(value => value.trim().toLocaleLowerCase()),
debounceTime(250),
distinctUntilChanged(),
switchMap(query => {
if (query.length < 2) return of<State>({kind: 'idle'});
return concat(
of<State>({kind: 'loading'}),
this.api.search(query).pipe(
map(results => ({kind: 'ready', results}) as State),
catchError(() => of<State>({kind: 'error', message: 'Search is unavailable.'})),
),
);
}),
),
{initialValue: {kind: 'idle'} as State},
);
updateQuery(value: string) {
this.queryChanges.next(value);
}
}
Client work is O(1) per input event plus rendering O(r) returned results. Only one active result request is subscribed at a time.
switchMap terminates the outer query stream.Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.