LearnAI ToolsCareerPractice BuildsPlayContact
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.