Debounce and Search Patterns
When users interact with filters or search fields, you do not want to fire an API call on every keystroke or every change. Debouncing delays the response until the user pauses, reducing unnecessary requests. distinctUntilChanged skips emissions when the value has not changed.
The problem without debouncing
Section titled “The problem without debouncing”Without debouncing, a valueChanges subscription on the Browse filter form fires an API call every time any control changes — even while the user is still adjusting filters:
User changes genre → API call 1User changes year → API call 2 (while call 1 may still be running)User changes sort → API call 3Three unnecessary calls, with the risk of results arriving out of order (the problem switchMap prevents). With debouncing, only the final state after the user pauses triggers a call.
debounceTime
Section titled “debounceTime”debounceTime(ms) waits for ms milliseconds of silence before emitting. If a new value arrives during the wait, the timer resets:
import { debounceTime } from 'rxjs/operators';
this.filterForm.valueChanges.pipe( debounceTime(400)).subscribe(values => { this.loadMovies(values);});If the user changes three controls within 400ms, only one subscription fires — after 400ms of no further changes.
distinctUntilChanged
Section titled “distinctUntilChanged”distinctUntilChanged() skips emissions when the value is the same as the previous emission. This is important when the user tabs through controls without changing values:
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
this.filterForm.valueChanges.pipe( debounceTime(400), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))).subscribe(values => { this.loadMovies(values);});For form groups, pass a custom comparison function since Angular compares object references by default (two different objects with the same values would not be considered equal without the custom comparator).
The complete Browse pattern
Section titled “The complete Browse pattern”Here is how CinemaVault’s Browse component wires the filter form to the API:
@Component({ /* ... */ })export class Browse implements OnInit, OnDestroy { private fb = inject(FormBuilder); private movieService = inject(MovieService); private sub?: Subscription;
filterForm = this.fb.group({ genre: [''], year: [''], sortBy: ['popularity.desc'] });
movies: Movie[] = []; loading = false;
ngOnInit(): void { // Load movies immediately with defaults this.loadMovies(this.filterForm.value);
// React to filter changes with debounce this.sub = this.filterForm.valueChanges.pipe( debounceTime(400), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), switchMap(values => { this.loading = true; return this.movieService.discover( values.genre ?? '', values.year ?? '', values.sortBy ?? 'popularity.desc' ).pipe( catchError(() => of([])) ); }) ).subscribe(movies => { this.movies = movies; this.loading = false; }); }
ngOnDestroy(): void { this.sub?.unsubscribe(); }
private loadMovies(values: typeof this.filterForm.value): void { this.loading = true; this.movieService.discover( values.genre ?? '', values.year ?? '', values.sortBy ?? 'popularity.desc' ).subscribe(movies => { this.movies = movies; this.loading = false; }); }}The pipeline: valueChanges → debounceTime(400) → distinctUntilChanged → switchMap (cancel old, start new) → API call.
The full operator chain explained
Section titled “The full operator chain explained”valueChanges → emits on every form changedebounceTime(400) → waits 400ms for changes to settledistinctUntilChanged() → skips if value hasn't actually changedswitchMap → cancels pending API call, starts fresh onecatchError → graceful fallback to empty arrayEach operator in the chain serves a specific purpose. Together they create a search/filter experience that is responsive but not wasteful.
Exercise
Section titled “Exercise”- Build a
Searchcomponent with a text input using aFormControl(not a fullFormGroup). - Subscribe to
searchControl.valueChangeswithdebounceTime(500)anddistinctUntilChanged(). - In the subscription, call a mock search function that logs
'Searching: ' + query. - Type rapidly and confirm the log appears only once, after you pause.
- Type the same query twice (clear and retype without changing). Confirm the log appears only once (thanks to
distinctUntilChanged).
debounceTime(ms)delays emissions until the user pauses formsmilliseconds — prevents API calls on every keystroke.distinctUntilChanged()skips emissions when the value matches the previous one — prevents redundant calls when no actual change occurred.- Use a custom comparator with form groups:
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)). - Combine with
switchMapto cancel in-flight requests when new values arrive. - Unsubscribe in
ngOnDestroyto prevent memory leaks.