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 `FormControl` with `[formControl]`.
- A `MovieSearchService` (`HttpClient`) that fetches movies for a given search term.
- A `debounceTime`/`distinctUntilChanged` pipeline so a request only fires after the user pauses typing.
- `switchMap` to automatically cancel a still-in-flight request the instant a newer search term arrives.
- `catchError` plus a `loading` signal, 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`, and `toSignal()` 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 with `combineLatest`.
- Click a card to open a detail view with the movie's longer description, fetched from a second `MovieSearchService` method.
- Save searched movies to a "favorites" list in `localStorage` using `CartService`'s `effect()`-based persistence pattern from the Shopping Cart project.
- Add `takeUntilDestroyed()` (from `@angular/core/rxjs-interop`) to the pipeline explicitly, and explain in a comment why `toSignal()` 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.