LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
AngularIntermediate~2.5 hours

Movie Search App

Search and display movies from an API with loading and error states.

HttpClientRxJS OperatorsReactive Forms

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.

What You'll Build
  • 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 styling
main.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.ts
import { 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.ts
import { 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.ts
import { 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 returns
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); // 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.ts
import { 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.ts
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);
}),
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.ts
import { 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

Sample Interaction

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.