@ngrx

component-store

Wojciech TrawiƄski

YouGov Front-end workshop, 2021

Introduction

Stand-alone library that helps to manage local/component state.

Alternative to reactive push-based "Service with a Subject".

Key concepts

  • state has to be initialized (can be done lazily),
  • state is tied to the life-cycle of a particular component and is cleaned up when that component is destroyed,
  • state can be updated through the setState and patchState methods or updaters,
  • state can be read through the select method or a top-level state$ observable,
  • side-effects can be performed with the effect method.

Initialization

export interface MoviesState {
  movies: Movie[];
}

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  constructor() {
    super({movies: []});
  }
}

Initialization

@Component({
  template: `
    <li *ngFor="let movie of (movies$ | async)">
      {{ movie.name }}
    </li>
  `,
  providers: [ComponentStore],
})
export class MoviesPageComponent {
  readonly movies$ = this.componentStore.state$.pipe(
    map(state => state.movies),
  );
 
  constructor(
    private readonly componentStore: ComponentStore<{movies: Movie[]}>
  ) {}
}

Reading state

export interface MoviesState {
  movies: Movie[];
}
 
@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  constructor() {
    super({movies:[]});
  }
 
  readonly movies$: Observable<Movie[]> = this.select(state => state.movies);
}

Reading state

@Component({
  template: `
    <li *ngFor="let movie of (movies$ | async)">
      {{ movie.name }}
    </li>
  `,
  providers: [MoviesStore],
})
export class MoviesPageComponent {
  readonly movies$ = this.moviesStore.movies$;
 
  constructor(private readonly moviesStore: MoviesStore) {}
}

Reading state

export interface MoviesState {
  movies: Movie[];
  userPreferredMoviesIds: string[];
}
 
@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  constructor() {
    super({movies:[], userPreferredMoviesIds:[]});
  }
 
  readonly movies$ = this.select(state => state.movies);
  readonly userPreferredMovieIds$ = this.select(state => state.userPreferredMoviesIds);
 
  readonly userPreferredMovies$ = this.select(
        this.movies$,
        this.userPreferredMovieIds$,
        (movies, ids) => movies.filter(movie => ids.includes(movie.id))
  );
}

Updating state

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  constructor() {
    super({movies: []});
  }

  readonly addMovie = this.updater((state, movie: Movie) => ({
    movies: [...state.movies, movie],
  }));
}

Updating state

@Component({
  template: `
    <button (click)="add('New Movie')">Add a Movie</button>
  `,
  providers: [MoviesStore],
})
export class MoviesPageComponent {
 
  constructor(private readonly moviesStore: MoviesStore) {}
 
  add(movie: string) {
    this.moviesStore.addMovie({ name: movie, id: generateId() });
  }
}

Updating state

@Component({
  template: `...`,
  providers: [ComponentStore],
})
export class MoviesPageComponent implements OnInit {
  constructor(
    private readonly componentStore: ComponentStore<MoviesState>
  ) {}
 
  resetMovies() {
    this.componentStore.setState({movies: []});
  }
 
  addMovie(movie: Movie) {
    this.componentStore.setState((state) => {
      return {
        ...state,
        movies: [...state.movies, movie],
      };
    });
  }
}

Updating state

@Component({
  template: `...`,
  providers: [ComponentStore],
})
export class MoviesPageComponent implements OnInit {
  constructor(
    private readonly componentStore: ComponentStore<MoviesState>
  ) {}
 
  updateSelectedMovie(selectedMovieId: string) {
    this.componentStore.patchState({selectedMovieId});
  }
 
  addMovie(movie: Movie) {
    this.componentStore.patchState((state) => ({
      movies: [...state.movies, movie]
    }));
  }
}

Exercise #1

Reading state

private readonly fetchMoviesData$ = this.select(
  this.store.select(getUserId),
  moviesPerPage$,
  currentPageIndex$,
  (userId, moviesPerPage, currentPageIndex) => ({userId, moviesPerPage, currentPageIndex}),
);

Exercise #2

Side effects

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  readonly getMovie = this.effect((movieId$: Observable<string>) => {
    return movieId$.pipe(
      switchMap((id) => this.moviesService.fetchMovie(id).pipe(
        tap({
          next: (movie) => this.addMovie(movie),
          error: (e) => this.logError(e),
        }),
        catchError(() => EMPTY),
      )),
    );
  });
 
  readonly addMovie = this.updater((state, movie: Movie) => ({
    movies: [...state.movies, movie],
  }));
}

Side effects

@Component({
  template: `...`,
  providers: [MoviesStore],
})
export class MovieComponent {
  @Input()
  set movieId(value: string) {
    this.moviesStore.getMovie(value);
  }
 
  constructor(private readonly moviesStore: MoviesStore) {}
}

Side effects

@Injectable()
export class MoviesStore extends ComponentStore<MoviesState> {
  
  constructor() {
    super({movies: Movie[], moviesPerPage: 10, currentPageIndex: 0});
 
    this.effect((moviePageData$) => {
      return moviePageData$.pipe(
        switchMap(({moviesPerPage, currentPageIndex}) =>
          this.movieService.loadMovies(moviesPerPage, currentPageIndex),
        ).pipe(tap(results => this.updateMovieResults(results))),
      );
    })(this.fetchMoviesData$);
  }
 
  private readonly fetchMoviesData$ = this.select(
    this.select(state => state.moviesPerPage),
    this.select(state => state.currentPageIndex),
    (moviesPerPage, currentPageIndex) => ({moviesPerPage, currentPageIndex}),
  );
}

Exercise #3/4

@ngrx/component-store

By wojtrawi

@ngrx/component-store

  • 311