Overview
A movie search app is the project where RxJS really earns its place, because it has to synchronize a component with something outside Angular's control — a network request — every time the user's search term changes. That brings in the exact real-world problems every data-fetching component eventually hits: showing a loading state while a request is pending, showing an error state if it fails, and — trickier — handling the case where the user types fast enough that an *older* request resolves after a *newer* one.
The search input is a Reactive Forms FormControl rather than a signal, because a FormControl comes with valueChanges — a built-in Observable stream that RxJS operators can be piped through directly. debounceTime, distinctUntilChanged, and switchMap handle the debounce and cancellation problems declaratively, and toSignal() bridges the resulting Observable back into a signal so the template reads it the same way it reads every other piece of state in this course. The search hits the iTunes Search API, a free, no-key, CORS-enabled endpoint that returns real movie data — no account or API key needed, which keeps the focus on Angular instead of authentication setup.
- A search input bound to a Reactive Forms
FormControlwith[formControl]. - A
MovieSearchService(HttpClient) that fetches movies for a given search term. - A
debounceTime/distinctUntilChangedpipeline so a request only fires after the user pauses typing. switchMapto automatically cancel a still-in-flight request the instant a newer search term arrives.catchErrorplus aloadingsignal, each driving its own conditionally-rendered piece of UI with@if/@else-if.- A responsive grid of movie cards built from the live API response.
Prerequisites
- Standalone components and signals — covered in the Todo App and Shopping Cart projects above.
- Promises/Observables at a conceptual level — an Observable is a stream of values over time, similar in spirit to a Promise that can emit more than once.
- Conditional rendering with
@if/@else-if/@else. - TypeScript interfaces, used to describe the shape of the API response.
HttpClient, RxJS operators,Reactive Forms, andtoSignal()are all new here — every one is introduced from scratch in this tutorial.
Project Structure
All of the interesting logic — building the request URL and mapping the raw response — lives in one injectable service, which keeps AppComponent itself small: it just owns the searchControl, pipes its valueChanges through the RxJS operators, and renders whatever the resulting signal holds.
src/app/ movie-search.service.ts // HttpClient-based search(term) method — the reusable core of the project app.component.ts // Search input + debounce/switchMap pipeline + conditional rendering app.component.css // Shared dark-theme stylingmain.ts // Bootstraps <AppComponent>, registers HttpClient via provideHttpClient()/* app.component.css */* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.movie-app { width: 100%; max-width: 640px; }.movie-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.movie-app input { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #23262e; color: #e8e8e8; font-size: 1rem; margin-bottom: 16px;}
.status { color: #9aa4b2; padding: 12px 0; }.status.error { color: #ff6b6b; }
.movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px;}
.movie-card { background: #23262e; border-radius: 8px; overflow: hidden; }.movie-card img { width: 100%; display: block; }.movie-card h3 { font-size: 0.85rem; padding: 8px 8px 2px; }.movie-card p { font-size: 0.75rem; color: #9aa4b2; padding: 0 8px 8px; }Step 1: Set Up HttpClient and a Reactive Forms Control
provideHttpClient() has to be registered once in main.ts before HttpClient can be injected anywhere in the app — without it, inject(HttpClient) throws at runtime. ReactiveFormsModule is added to the component's own imports array (standalone components import exactly what their template uses) so [formControl] can bind the input to a FormControl instance.
// main.tsimport { bootstrapApplication } from '@angular/platform-browser';import { provideHttpClient } from '@angular/common/http';import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, { providers: [provideHttpClient()], // registers HttpClient so inject(HttpClient) resolves anywhere in the app}).catch((err) => console.error(err));// app.component.tsimport { Component } from '@angular/core';import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({ selector: 'app-root', standalone: true, imports: [ReactiveFormsModule], templateUrl: './app.component.html', styleUrls: ['./app.component.css'],})export class AppComponent { // A Reactive Forms FormControl instead of a signal — it comes with // valueChanges as a built-in Observable, exactly what's needed to pipe // through RxJS operators like debounceTime and switchMap in Step 3. // { nonNullable: true } keeps its type as plain string instead of string | null readonly searchControl = new FormControl('', { nonNullable: true });}<!-- app.component.html --><div class="movie-app"> <h1>Movie Search</h1> <input [formControl]="searchControl" type="text" placeholder="Search for a movie..." /> <!-- Results, loading, and error rendering are added in Steps 5 and 6 --></div>Step 2: Build the MovieSearchService
MovieSearchService wraps one HttpClient.get() call and maps the raw response down to just the results array — every component that needs movie data calls search(term) and gets back an Observable<Movie[]>, without needing to know the iTunes API's exact response shape.
// movie-search.service.tsimport { HttpClient } from '@angular/common/http';import { Injectable, inject } from '@angular/core';import { Observable } from 'rxjs';import { map } from 'rxjs/operators';
export interface Movie { trackId: number; trackName: string; artworkUrl100: string; releaseDate: string;}
// Shape of the raw iTunes Search API response — only "results" is used, but// naming the full shape documents what the endpoint actually returnsinterface ItunesSearchResponse { resultCount: number; results: Movie[];}
@Injectable({ providedIn: 'root' })export class MovieSearchService { private readonly http = inject(HttpClient);
search(term: string): Observable<Movie[]> { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(term); // encodeURIComponent safely escapes spaces/punctuation in the search term
// HttpClient.get() already parses JSON and returns an Observable that // emits once and completes — map() narrows it down to just the array // callers actually care about return this.http.get<ItunesSearchResponse>(url).pipe(map((response) => response.results)); }}Step 3: Debounce and Switch with RxJS Operators
debounceTime(400) waits for a 400ms pause in typing before letting a value through, and distinctUntilChanged() skips re-searching if the debounced value is identical to the last one. switchMap is the key operator for cancellation: every time valueChanges emits a new search term, switchMap unsubscribes from whatever inner Observable (the previous HTTP request) it was still waiting on and switches to a new one — the same "cancel the stale request" job AbortController does for a raw fetch() call, but built directly into the operator instead of managed by hand.
// app.component.ts (search pipeline)import { Component, inject, signal } from '@angular/core';import { FormControl, ReactiveFormsModule } from '@angular/forms';import { toSignal } from '@angular/core/rxjs-interop';import { of } from 'rxjs';import { debounceTime, distinctUntilChanged, startWith, switchMap, tap } from 'rxjs/operators';import { Movie, MovieSearchService } from './movie-search.service';
@Component({ selector: 'app-root', standalone: true, imports: [ReactiveFormsModule], templateUrl: './app.component.html', styleUrls: ['./app.component.css'],})export class AppComponent { private readonly movieService = inject(MovieSearchService);
readonly searchControl = new FormControl('', { nonNullable: true }); readonly loading = signal(false); // set imperatively from inside the pipeline below, not derived from another signal
private readonly movies$ = this.searchControl.valueChanges.pipe( startWith(''), // emits once immediately so the template has a value before the user types anything debounceTime(400), // waits for a pause in typing before continuing distinctUntilChanged(), // skips re-searching if the debounced value didn't actually change tap(() => this.loading.set(true)), switchMap((term) => { const trimmed = term.trim(); if (trimmed === '') { this.loading.set(false); return of<Movie[]>([]); // an empty search term means an empty result set, no request needed } // switchMap cancels whatever previous inner request was still in // flight the instant a new search term arrives here return this.movieService.search(trimmed).pipe(tap(() => this.loading.set(false))); }) );
// toSignal() bridges the RxJS Observable above into a signal so the // template can read movies() the same way it reads any other signal readonly movies = toSignal(this.movies$, { initialValue: [] as Movie[] });}This version works for successful searches, but a rejected HttpClient request would currently propagate as an error straight through the Observable and stop movies$ from ever emitting again for any future search term — exactly the problem catchError solves in the next step.
Step 4: Handle Errors with catchError
catchError is placed *inside* the switchMap's inner pipe, not around the whole chain — that placement matters: catching the error there recovers just that one failed inner request and lets movies$ keep emitting for future search terms, whereas catching it around the outer movies$ would kill the entire subscription after the very first failure. A parallel error signal, updated the same imperative way as loading, drives the error message in the template.
// app.component.ts (final search pipeline)import { Component, inject, signal } from '@angular/core';import { FormControl, ReactiveFormsModule } from '@angular/forms';import { toSignal } from '@angular/core/rxjs-interop';import { of } from 'rxjs';import { catchError, debounceTime, distinctUntilChanged, startWith, switchMap, tap } from 'rxjs/operators';import { Movie, MovieSearchService } from './movie-search.service';
@Component({ selector: 'app-root', standalone: true, imports: [ReactiveFormsModule], templateUrl: './app.component.html', styleUrls: ['./app.component.css'],})export class AppComponent { private readonly movieService = inject(MovieSearchService);
readonly searchControl = new FormControl('', { nonNullable: true }); readonly loading = signal(false); readonly error = signal<string | null>(null);
private readonly movies$ = this.searchControl.valueChanges.pipe( startWith(''), debounceTime(400), distinctUntilChanged(), tap(() => { this.loading.set(true); this.error.set(null); // clear any previous error the moment a new search starts }), switchMap((term) => { const trimmed = term.trim(); if (trimmed === '') { this.loading.set(false); return of<Movie[]>([]); } return this.movieService.search(trimmed).pipe( tap(() => this.loading.set(false)), // catchError lives HERE, inside switchMap's inner pipe — it recovers // just this one failed request and returns an empty array, so // movies$ itself never errors out and future searches still work catchError(() => { this.loading.set(false); this.error.set('Search request failed.'); return of<Movie[]>([]); }) ); }) );
readonly movies = toSignal(this.movies$, { initialValue: [] as Movie[] });}Step 5: Render Loading, Error, and Empty States
Each of the four states — loading, error, no-results, and idle — is checked with @if/@else if/@else, so exactly one of them ever renders at a time.
<!-- app.component.html (status block) -->@if (loading()) { <p class="status">Loading...</p>} @else if (error()) { <p class="status error">{{ error() }}</p>} @else if (searchControl.value.trim() !== '' && movies().length === 0) { <p class="status">No movies found for "{{ searchControl.value }}".</p>}Step 6: Render the Movie Grid
Once movies() has results, @for turns each one into a card. The iTunes API returns trackId (a stable unique id, used for track), trackName, artworkUrl100 (a small poster image), and releaseDate as an ISO string — .slice(0, 4) pulls just the year out of it for display.
<!-- app.component.html (movie grid) --><div class="movie-grid"> @for (movie of movies(); track movie.trackId) { <div class="movie-card"> <img [src]="movie.artworkUrl100" [alt]="movie.trackName" /> <h3>{{ movie.trackName }}</h3> <p>{{ movie.releaseDate ? movie.releaseDate.slice(0, 4) : 'Unknown year' }}</p> </div> }</div>Complete Code
Here is the complete project, using the real iTunes Search API exactly as you would call it from a live page.
/* app.component.css */* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px; }.movie-app { width: 100%; max-width: 640px; }.movie-app h1 { margin-bottom: 16px; font-size: 1.5rem; }.movie-app input { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #23262e; color: #e8e8e8; font-size: 1rem; margin-bottom: 16px; }.status { color: #9aa4b2; padding: 12px 0; }.status.error { color: #ff6b6b; }.movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px; }.movie-card { background: #23262e; border-radius: 8px; overflow: hidden; }.movie-card img { width: 100%; display: block; }.movie-card h3 { font-size: 0.85rem; padding: 8px 8px 2px; }.movie-card p { font-size: 0.75rem; color: #9aa4b2; padding: 0 8px 8px; }// movie-search.service.tsimport { HttpClient } from '@angular/common/http';import { Injectable, inject } from '@angular/core';import { Observable } from 'rxjs';import { map } from 'rxjs/operators';
export interface Movie { trackId: number; trackName: string; artworkUrl100: string; releaseDate: string;}
interface ItunesSearchResponse { resultCount: number; results: Movie[];}
@Injectable({ providedIn: 'root' })export class MovieSearchService { private readonly http = inject(HttpClient);
search(term: string): Observable<Movie[]> { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(term); return this.http.get<ItunesSearchResponse>(url).pipe(map((response) => response.results)); }}// app.component.tsimport { Component, inject, signal } from '@angular/core';import { FormControl, ReactiveFormsModule } from '@angular/forms';import { toSignal } from '@angular/core/rxjs-interop';import { of } from 'rxjs';import { catchError, debounceTime, distinctUntilChanged, startWith, switchMap, tap } from 'rxjs/operators';import { Movie, MovieSearchService } from './movie-search.service';
@Component({ selector: 'app-root', standalone: true, imports: [ReactiveFormsModule], templateUrl: './app.component.html', styleUrls: ['./app.component.css'],})export class AppComponent { private readonly movieService = inject(MovieSearchService);
readonly searchControl = new FormControl('', { nonNullable: true }); readonly loading = signal(false); readonly error = signal<string | null>(null);
private readonly movies$ = this.searchControl.valueChanges.pipe( startWith(''), debounceTime(400), distinctUntilChanged(), tap(() => { this.loading.set(true); this.error.set(null); }), switchMap((term) => { const trimmed = term.trim(); if (trimmed === '') { this.loading.set(false); return of<Movie[]>([]); } return this.movieService.search(trimmed).pipe( tap(() => this.loading.set(false)), catchError(() => { this.loading.set(false); this.error.set('Search request failed.'); return of<Movie[]>([]); }) ); }) );
readonly movies = toSignal(this.movies$, { initialValue: [] as Movie[] });}<!-- app.component.html --><div class="movie-app"> <h1>Movie Search</h1> <input [formControl]="searchControl" type="text" placeholder="Search for a movie..." />
@if (loading()) { <p class="status">Loading...</p> } @else if (error()) { <p class="status error">{{ error() }}</p> } @else if (searchControl.value.trim() !== '' && movies().length === 0) { <p class="status">No movies found for "{{ searchControl.value }}".</p> }
<div class="movie-grid"> @for (movie of movies(); track movie.trackId) { <div class="movie-card"> <img [src]="movie.artworkUrl100" [alt]="movie.trackName" /> <h3>{{ movie.trackName }}</h3> <p>{{ movie.releaseDate ? movie.releaseDate.slice(0, 4) : 'Unknown year' }}</p> </div> } </div></div>// main.tsimport { bootstrapApplication } from '@angular/platform-browser';import { provideHttpClient } from '@angular/common/http';import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, { providers: [provideHttpClient()],}).catch((err) => console.error(err));Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a "Load More" button that requests the next page of results and appends them to the signal.
- Add a genre or media-type dropdown (movie / tv show / music) as a second
FormControl, combined withcombineLatest. - Click a card to open a detail view with the movie's longer description, fetched from a second
MovieSearchServicemethod. - Save searched movies to a "favorites" list in
localStorageusingCartService'seffect()-based persistence pattern from the Shopping Cart project. - Add
takeUntilDestroyed()(from@angular/core/rxjs-interop) to the pipeline explicitly, and explain in a comment whytoSignal()already handles cleanup automatically without it.
Summary
You built a real, network-connected search feature using a Reactive Forms FormControl as the source of an RxJS pipeline, debounceTime/distinctUntilChanged to tame rapid input, switchMap to cancel stale requests automatically, catchError to recover from failures without breaking future searches, and toSignal() to bridge the whole pipeline back into the signal-based rendering used throughout this course. This exact shape — Reactive Forms feeding an RxJS operator chain, bridged to a signal — is the pattern you will reach for in almost any Angular component that searches or fetches data as the user types.