Platform: Add EntityComponentStore

Created on 13 Nov 2020  路  5Comments  路  Source: ngrx/platform

With EntityComponentStore, the code that is repeated in most component stores will be reduced.

Prototype: EDIT: Improved types

export interface EntityState<
  Id extends string | number,
  Entity extends { id: Id }
> {
  ids: Id[];
  entities: Record<Id, Entity>;
}

@Injectable()
export class EntityComponentStore<
  State extends EntityState<Id, Entity>,
  Id extends string | number = State extends EntityState<infer I, any> ? I : never,
  Entity extends { id: Id } = State extends EntityState<any, infer E> ? E : never
> extends ComponentStore<State> {
  constructor(defaultState?: State) {
    super(defaultState);
  }

  /**
   * Selectors
   */
  readonly ids$ = this.select((state) => state.ids);
  readonly entities$ = this.select((state) => state.entities);
  readonly all$ = this.select(this.ids$, this.entities$, (ids, entities) =>
    ids.map((id) => entities[id])
  );
  readonly total$ = this.select(this.ids$, (ids) => ids.length);

  /**
   * Updaters
   */
  readonly addOne = this.updater((state, entity: Entity) => ({
    ...state,
    ids: [...state.ids, entity.id],
    entities: { ...state.entities, [entity.id]: entity },
  }));
  readonly addMany = this.updater((state, entities: Entity[]) => ({
    ...state,
    ids: [...state.ids, ...entities.map((entity) => entity.id)],
    entities: {
      ...state.entities,
      ...entities.reduce(
        (acc, entity) => ({
          ...acc,
          [entity.id]: entity,
        }),
        {} as Record<Id, Entity>
      ),
    },
  }));
  /** etc. **/
}

Usage:

export interface Movie {
  id: number;
  name: string;
  visible: boolean;
}

export interface MoviesState extends EntityState<number, Movie> {}

@Injectable()
export class MoviesStore extends EntityComponentStore<MoviesState> {
  constructor(private readonly moviesService: MoviesService) {
    super({ ids: [], entities: {} });
  }

  readonly visibleMovies$ = this.select(this.all$, (movies) =>
    movies.filter((movie) => movie.visible)
  );

  readonly getMovie = this.effect((id$: Observable<number>) =>
    id$.pipe(
      switchMap((id) =>
        this.moviesService.getMovie(id).pipe(
          tapResponse(
            (movie) => this.addOne(movie),
            (error) => console.error(error)
          )
        )
      )
    )
  );
}

If accepted, I would be willing to submit a PR for this feature

[x] Yes (Assistance is provided if you need help submitting a pull request)
[ ] No

Component Store enhancement

All 5 comments

I'd rather have the @ngrx/entity to be stripped from @ngrx/store dep.
I'm already using adapter with my ComponentStores and @ngrx/entity 馃檪

Granted, this is even further reduction of code.
If anything, this could be another lib (maybe even with ngrx family of libraries).

@timdeschryver @brandonroberts Thoughts? 馃檪

@alex-okrushko
Yes, @ngrx/entity and @ngrx/component-store are great combination. This advice is to have @ngrx/entity features adopted to the @ngrx/component-store object-oriented nature 馃檪

By the way, Entity Component Store as a separate library in ngrx/platform also seems better to me 馃憤

I think this can be accomplished with splitting out selectors into a separate library that we already discussed. That decouples entity from the store library also. I'm not sure about making a separate library for each Store/ComponentStore combination.

Simple solution is

// Angular specific
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { EMPTY, Observable } from 'rxjs';
import { catchError, concatMap, finalize, tap } from 'rxjs/operators';

// NGRX specific
import { ComponentStore } from '@ngrx/component-store';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { EntityMapOne, EntitySelectors } from '@ngrx/entity/src/models';

export interface Model {
  id: string;
  // ... more fields
}

interface State {
  models: EntityState<Model>;
  error: Error;
  loading: boolean;
}

@Injectable()
export class MyStore extends ComponentStore<State> {
  private entityAdapter: EntityAdapter<Model>;
  private entitySelectors: EntitySelectors<Model, State>;

  constructor(private modelService: ModelService) {
    super();

    this.entityAdapter = createEntityAdapter<Model>({
      selectId: (model) => model.id,
      sortComparer: null,
    });
    this.entitySelectors = this.entityAdapter.getSelectors(
      (state) => state.models
    );

    this.setState({
      models: this.entityAdapter.getInitialState(),
      error: null,
      loading: false,
    });
  }

  /**
   * Effects
   * */
  readonly loadAll = this.effect((origin$: Observable<void>) =>
    origin$.pipe(
      tap(() => this.setLoading(true)),
      concatMap(() =>
        this.modelService.getAll().pipe(
          tap((models) => {
            this.setAll(models);
          }),
          catchError((error: HttpErrorResponse) => {
            this.setError(error.error);
            return EMPTY;
          }),
          finalize(() => {
            this.setLoading(false);
          })
        )
      )
    )
  );

  /**
   * Updaters
   * */
  readonly setAll = this.updater((state, models: Model[]) => ({
    ...state,
    models: this.entityAdapter.setAll(models, state.models),
  }));

  readonly mapOne = this.updater((state, map: EntityMapOne<Model>) => ({
    ...state,
    models: this.entityAdapter.mapOne(map, state.models),
  }));

  readonly setError = this.updater((state, error: Error) => ({
    ...state,
    error,
  }));

  readonly setLoading = this.updater((state, loading: boolean) => ({
    ...state,
    loading,
  }));

  /**
   * Selectors
   * */
  readonly loading$ = this.select((state) => state.loading);

  readonly all$ = this.select((state) => this.entitySelectors.selectAll(state));
  readonly entityMap$ = this.select((state) =>
    this.entitySelectors.selectEntities(state)
  );
  readonly ids$ = this.select((state) => this.entitySelectors.selectIds(state));
  readonly total$ = this.select((state) =>
    this.entitySelectors.selectTotal(state)
  );

  /**
   * Abstraction over entity (model) operations
   * */
  private deeplyUpdateModel(id: string) {
    this.mapOne({
      id,
      map: (model) => ({
        ...model,
        // update here
      }),
    });
  }
}

@Injectable({
  providedIn: 'root',
})
export class ModelService {
  private readonly apiUrl: string;

  constructor(private httpClient: HttpClient) {
    this.apiUrl = 'https://my-api/models';
  }

  getAll(): Observable<Model[]> {
    return this.httpClient.get<Model[]>(this.apiUrl);
  }
}

function logger(state: any) {
  console.groupCollapsed('%c[NewRegisteredProfiles] state', 'color: skyblue;');
  console.log(state);
  console.groupEnd();
}

@Ash-kosakyan thanks for suggestion.

```typescript
/**

  • Effects
  • */
    readonly loadAll = this.effect((origin$: Observable) =>
    origin$.pipe(
    tap(() => this.setLoading(true)),
    concatMap(() =>
    this.modelService.getAll().pipe(
    tap((models) => {
    this.setAll(models);
    }),
    catchError((error: HttpErrorResponse) => {
    this.setError(error.error);
    return EMPTY;
    }),
    finalize(() => {
    this.setLoading(false);
    })
    )
    )
    )
    );
    ```

I'd rather avoid predefined effects, because that would have a lot of limitations (similar to ngrx/data limitations).

Btw, entity updaters could accept partial updater as an optional second argument, for more flexibility:

this.setAll(entities, { loading: false });
Was this page helpful?
0 / 5 - 0 ratings

Related issues

NathanWalker picture NathanWalker  路  3Comments

gperdomor picture gperdomor  路  3Comments

axmad22 picture axmad22  路  3Comments

brandonroberts picture brandonroberts  路  3Comments

shyamal890 picture shyamal890  路  3Comments