Search code examples
javascriptangulartypescriptthemoviedb-api

How do isolate an API call to a single component in this Angular 16 app?


I have been working on an SPA with Angular 16, TypeScript and The Movie Database (TMDB).

I have encountered a problem after the development of an "infinite scroll" feature.

In the MoviesByGenre component I have:

import { Component } from '@angular/core';
import { GenreResponse, Genre } from '../../models/Genre';
import { MovieResponse, Movie } from '../../models/Movie';
import { MovieService } from '../../services/movie-service.service';
import { ActivatedRoute } from '@angular/router';
import { distinctUntilChanged, fromEvent, map, startWith } from 'rxjs';

@Component({
  selector: 'app-movies-by-genre',
  templateUrl: './movies-by-genre.component.html',
  styleUrls: ['./movies-by-genre.component.scss']
})

export class MoviesByGenre {
  constructor(
    private activatedRoute: ActivatedRoute,
    private movieService: MovieService
  ) { }

  public genreName: string | undefined = '';
  public movieResponse!: MovieResponse;
  public movies: Movie[] = [];
  public genreResponse!: GenreResponse;
  public genres: Genre[] | undefined = [];
  public genreId!: number;
  public maxPage: number = 10;
  public pageNumber: number = 1;
  public isLoading: boolean = false;

  public getMoviesByGenre(): void {
    // Get genre id (from URL parameter)
    this.genreId = Number(this.activatedRoute.snapshot.paramMap.get('id'));

    // Get genre name from genres array
    this.movieService.getAllMovieGenres().subscribe((response) => {
      this.genreResponse = response;
      this.genres = this.genreResponse.genres;

      if (this.genres && this.genres.length) {
        let currentGenre = this.genres.find(
          (genre) => genre.id === this.genreId
        );
        if (currentGenre) {
          this.genreName = currentGenre.name || '';
          this.movieService.defaultTitle = this.genreName;
        }
      }
    });

    this.loadMoreMovies(this.genreId, this.pageNumber);
  }

  public loadMoreMovies(genreId: number, pageNumber: number) {
    // Get movies by genre id
    this.movieService
      .getMoviesByGenre(genreId, pageNumber)
      .subscribe((response) => {
        this.movieResponse = response;
        this.movies.push(...(this.movieResponse?.results || []));
      });
  }

  ngAfterViewInit() {
    fromEvent(window, 'scroll')
      .pipe(
        startWith(0),
        map(() => window?.scrollY),
        distinctUntilChanged()
      )
      .subscribe((scrollPos: any) => {
        if (!this.movies?.length) {
          return;
        }
        if (
          Math.round(scrollPos + window.innerHeight) >=
          document.documentElement.scrollHeight &&
          this.pageNumber < this.maxPage
        ) {
          this.pageNumber++;
          this.loadMoreMovies(this.genreId, this.pageNumber);
        }
      });
  }

  ngOnInit() {
    this.activatedRoute.params.subscribe(() => {
      this.movies = [];
      this.getMoviesByGenre();
    });
  }

  ngOnDestroy() {
    this.movieService.defaultTitle = '';
  }
}

In the MovieService service:

import { environment } from '../../environments/environment';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { MovieResponse, Movie } from '../models/Movie';
import { GenreResponse } from '../models/Genre';
import { TrailerResponse } from '../models/Trailer';

@Injectable({
  providedIn: 'root'
})

export class MovieService {

  constructor(private http: HttpClient) { }

  public defaultTitle: string = '';


  public getAllMovieGenres(): Observable<GenreResponse> {
    return this.http.get<GenreResponse>(`${environment.apiUrl}/genre/movie/list?api_key=${environment.apiKey}`);
  }

  public getMoviesByGenre(id: Number, pageNumber: Number): Observable<MovieResponse> {
    return this.http.get<MovieResponse>(`${environment.apiUrl}/discover/movie?api_key=${environment.apiKey}&with_genres=${id}&page=${pageNumber}`);
  }

  public getMovieDetails(id: Number): Observable<Movie>{
    return this.http.get<Movie>(`${environment.apiUrl}/movie/${id}?api_key=${environment.apiKey}`);
  }
}

On each scroll to the bottom of the page on the by-genre/:id route, 20 more movies are loaded, with a limit set at 10 pages (200 movies).

The problem

The problem is that even on other routes, like the movie details route, an API call is made upon scrolling to the bottom of the page and that shows the loading spinner, for which I have used a http interceptor technique, unnecessarily.

The goal

The goal is to make the infinite scroll specific to the MoviesByGenre component:

For this purpose, I have added a Boolean in the MoviesByGenre component and used it as follows:

public isLoadMore: boolean = true;

if (this.isLoadMore) {
  this.loadMoreMovies(this.genreId, this.pageNumber);
} 

But the request are still made on other components upon scrolling to the bottom of the page.

It looks like I failed in both identifying the cause of the problem and providing a viable solution.

Questions

  1. What causes this problem?
  2. What is the most reliable way of fixing it?

Solution

  • We have to unsubscribe to the active subscriptions to resolve this issue, when the component get destroyed, it does not necessarily mean the subscriptions are destroyed. We have to add all of them to a subscription and call the unsubscribe method on ngOnDestroy of component to kill all the subscriptions.

    It's a good practice to add all subscriptions to a subscription property and then unsubscribe them altogether.

    import { Component } from '@angular/core';
    import { GenreResponse, Genre } from '../../models/Genre';
    import { MovieResponse, Movie } from '../../models/Movie';
    import { MovieService } from '../../services/movie-service.service';
    import { ActivatedRoute } from '@angular/router';
    import {
      Subscription,
      distinctUntilChanged,
      fromEvent,
      map,
      startWith,
    } from 'rxjs';
    
    @Component({
      selector: 'app-movies-by-genre',
      templateUrl: './movies-by-genre.component.html',
      styleUrls: ['./movies-by-genre.component.scss'],
    })
    export class MoviesByGenre {
      private subscription = new Subscription(); // <- changed here!
      constructor(
        private activatedRoute: ActivatedRoute,
        private movieService: MovieService
      ) {}
    
      public genreName: string | undefined = '';
      public movieResponse!: MovieResponse;
      public movies: Movie[] = [];
      public genreResponse!: GenreResponse;
      public genres: Genre[] | undefined = [];
      public genreId!: number;
      public maxPage: number = 10;
      public pageNumber: number = 1;
      public isLoading: boolean = false;
    
      public getMoviesByGenre(): void {
        // Get genre id (from URL parameter)
        this.genreId = Number(this.activatedRoute.snapshot.paramMap.get('id'));
    
        // Get genre name from genres array
        this.subscription.add( // <- changed here!
          this.movieService.getAllMovieGenres().subscribe((response) => {
            this.genreResponse = response;
            this.genres = this.genreResponse.genres;
    
            if (this.genres && this.genres.length) {
              let currentGenre = this.genres.find(
                (genre) => genre.id === this.genreId
              );
              if (currentGenre) {
                this.genreName = currentGenre.name || '';
                this.movieService.defaultTitle = this.genreName;
              }
            }
          })
        );
    
        this.loadMoreMovies(this.genreId, this.pageNumber);
      }
    
      public loadMoreMovies(genreId: number, pageNumber: number) {
        // Get movies by genre id
        this.subscription.add( // <- changed here!
          this.movieService
            .getMoviesByGenre(genreId, pageNumber)
            .subscribe((response) => {
              this.movieResponse = response;
              this.movies.push(...(this.movieResponse?.results || []));
            })
        );
      }
    
      ngAfterViewInit() {
        this.subscription.add( // <- changed here!
          fromEvent(window, 'scroll')
            .pipe(
              startWith(0),
              map(() => window?.scrollY),
              distinctUntilChanged()
            )
            .subscribe((scrollPos: any) => {
              if (!this.movies?.length) {
                return;
              }
              if (
                Math.round(scrollPos + window.innerHeight) >=
                  document.documentElement.scrollHeight &&
                this.pageNumber < this.maxPage
              ) {
                this.pageNumber++;
                this.loadMoreMovies(this.genreId, this.pageNumber);
              }
            })
        );
      }
    
      ngOnInit() {
        this.subscription.add( // <- changed here!
          this.activatedRoute.params.subscribe(() => {
            this.movies = [];
            this.getMoviesByGenre();
          })
        );
      }
    
      ngOnDestroy() {
        this.subscription.unsubscribe(); // <- changed here!
        this.movieService.defaultTitle = '';
      }
    }