import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
export interface SearchResult {
id: string;
label: string;
sublabel?: string;
}
@Injectable({ providedIn: 'root' })
export class SearchService {
private readonly endpoint = '/api/search';
constructor(private http: HttpClient) {}
search(term: string): Observable<SearchResult[]> {
const trimmed = term.trim();
if (!trimmed) {
return of([]);
}
const params = new HttpParams().set('q', trimmed).set('limit', '8');
return this.http.get<SearchResult[]>(this.endpoint, { params }).pipe(
catchError(() => of([]))
);
}
}
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
map,
switchMap,
tap,
} from 'rxjs/operators';
import { SearchService, SearchResult } from './search.service';
@Component({
selector: 'app-typeahead-search',
templateUrl: './typeahead-search.component.html',
})
export class TypeaheadSearchComponent {
readonly searchControl = new FormControl('');
loading = false;
searched = false;
readonly results$: Observable<SearchResult[]> = this.searchControl.valueChanges.pipe(
map((value) => (value ?? '').trim()),
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this.loading = true;
this.searched = true;
}),
switchMap((term) => this.searchService.search(term)),
tap(() => (this.loading = false))
);
constructor(private searchService: SearchService) {}
trackById(_index: number, item: SearchResult): string {
return item.id;
}
}
<div class="typeahead">
<input
type="text"
class="typeahead__input"
placeholder="Search…"
autocomplete="off"
[formControl]="searchControl"
/>
<ng-container *ngIf="results$ | async as results">
<div class="typeahead__status" *ngIf="loading">Searching…</div>
<ul class="typeahead__list" *ngIf="!loading && results.length">
<li
class="typeahead__item"
*ngFor="let result of results; trackBy: trackById"
>
<span class="typeahead__label">{{ result.label }}</span>
<small *ngIf="result.sublabel">{{ result.sublabel }}</small>
</li>
</ul>
<div
class="typeahead__empty"
*ngIf="!loading && searched && !results.length"
>
No matches found.
</div>
</ng-container>
</div>
This snippet shows the canonical way to build a typeahead search in Angular using a reactive form control, RxJS operators, and an HTTP-backed service. The core problem is that a naive keyup handler fires a request on every keystroke, which floods the backend and produces flickering, out-of-order results. The combination of debounceTime, distinctUntilChanged, and switchMap solves all three issues at once.
In search.service.ts, SearchService exposes a single search method that returns a cold Observable of SearchResult[]. It short-circuits blank terms with of([]) so no HTTP call is made for empty input, and it uses catchError to swallow transient failures and keep the outer stream alive — a request error should degrade to an empty list, not tear down the whole typeahead. Returning an observable rather than a promise is what lets the component cancel in-flight work.
In typeahead-search.component.ts, the searchControl is a plain FormControl, and its valueChanges stream is the input to the pipeline. debounceTime(300) waits for a pause in typing before emitting, so bursts of keystrokes collapse into one term. distinctUntilChanged drops emissions where the term is unchanged, avoiding redundant calls when the value settles back to a prior string. The crucial operator is switchMap: each new term unsubscribes from the previous inner search observable, which cancels the outstanding HTTP request and guarantees that only the latest query's results ever reach the view. This is what prevents the classic race where a slow response for an old term overwrites a fresh one.
The results$ stream also toggles a loading flag via tap, and everything is rendered through the async pipe in typeahead-search.component.html, so there is no manual subscription to leak and no ngOnDestroy teardown to write. The template guards on loading and an empty-but-searched state to show sensible feedback. Reaching for this pattern makes sense whenever remote lookups are driven by user input; the main trade-off is choosing a debounce interval that balances responsiveness against request volume.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
package deps
import (
"crypto/tls"
"crypto/x509"
"net/http"
mTLS client configuration with custom root CA pool
Share this code
Here's the card — post it anywhere.