Search code examples
angularrxjsngrxngrx-storengrx-entity

Select slice consisted of several entities in NgRx Store


I use NgRx Entities to create state for 'logs' reducer consisted of Log entities: EntityState<Log>. Then I want to subscribe from my Angular component to several Log entities. If it was only one Log, I would use:

this.store$
  .select(appStore => appStore.logs.entities[myLogId])
  .subscribe(log => someExpensiveOperation())

How can I select several entities and make sure subscribe fires only once if more the one of these entities has been changed?


Solution

  • It is a bit tricker than it sounds. There are two directions I have tried.

    The first is to filter the list using a map operator. The map will be called whenever any entity changes in the list so you have to have an operator after it to ignore duplicates. Since the map will create a new array each time you can't use a standard distinct* operator to filter it out. I created a custom operator named distinctElements which is basically distinctUntilChanged but it does a reference check on the elements of the array rather than the array itself. For this example I assume that you are using the selectAll selector that is generated by the entity adapter. It exposes an array of all of the entities.

    const { Observable, BehaviorSubject } = rxjs;
    const { startWith, pairwise, filter, map, tap } = rxjs.operators;
    
    function distinctElements(){
        return (source) => source.pipe(
            startWith(null),
            pairwise(),
            filter(([a, b]) => a == null || a.length !== b.length || a.some(x => !b.includes(x))),
            map(([a, b]) => b)
        );
    };
    
    let state = [
      { id: 1, value: 'a' },
      { id: 2, value: 'b' },
      { id: 3, value: 'c' }
    ];
    const store$ = new BehaviorSubject(state);
    
    const ids = [1, 3];
    store$.pipe(
      map(entities => entities.filter(entity => ids.includes(entity.id))),
      distinctElements()
    ).subscribe((entities) => { console.log('next', entities); });
    
    setTimeout(() => {
      state = [...state, { id: 4, value: 'd' }];
      console.log('add entity (4)');
      store$.next(state);
    }, 10);
    
    setTimeout(() => {
      state[0].value = 'aa';
      state = [{...state[0]}, ...state.slice(1)];
      console.log('update entity (1)');
      store$.next(state);
    }, 1000);
    
    setTimeout(() => {
      state = [...state.slice(0, 1), ...state.slice(2)];
      console.log('remove entity (2)');
      store$.next(state);
    }, 2000);
    <script src="https://unpkg.com/rxjs@rc/bundles/rxjs.umd.min.js"></script>

    The second option would be to create separate observables for each entity and to do a combineLatest on all of them. For this example I assume that you are using the selectEntities selector that is generated by the entity adapter. This one exposes an object which is indexable by the entity's id.

    const { Observable, BehaviorSubject, combineLatest, timer } = rxjs;
    const { startWith, pairwise, filter, map, debounce, distinctUntilChanged } = rxjs.operators;
    
    function distinctElements(){
        return (source) => source.pipe(
            startWith(null),
            pairwise(),
            filter(([a, b]) => a == null || a.length !== b.length || a.some(x => !b.includes(x))),
            map(([a, b]) => b)
        );
    };
    
    let state = {
      1: { id: 1, value: 'a' },
      2: { id: 2, value: 'b' },
      3: { id: 3, value: 'c' }
    };
    const store$ = new BehaviorSubject(state);
    
    const ids = [1, 3];
    combineLatest(
      ids.map(id => store$.pipe(
        map(entities => entities[id]),
        distinctUntilChanged()
      ))
    ).pipe(
      debounce(() => timer(0))
    ).subscribe((entities) => { console.log('next', entities); });
    
    setTimeout(() => {
      state = { ...state, 4: { id: 4, value: 'd' } };
      console.log('add entity (4)');
      store$.next(state);
    }, 10);
    
    setTimeout(() => {
      state[1].value = 'aa';
      state = { ...state, 1: {...state[1]} };
      console.log('update entity (1)');
      store$.next(state);
    }, 1000);
    
    setTimeout(() => {
      state = { ...state };
      delete state[2];
      console.log('remove entity (2)');
      store$.next(state);
    }, 2000);
    <script src="https://unpkg.com/rxjs@rc/bundles/rxjs.umd.min.js"></script>

    Both accomplish the same thing but in different ways. I haven't done any kind of performance test to see which is better but it is probably dependent on the number of entities you are selecting relative to the total size of the list. If I had to guess I would assume that the first is more performant.

    In regards to selecting a slice of projected data combining multiple slices you could reference this answer: Denormalizing ngrx store- setting up selectors?