@ngry/store

Reactive state management for Angular

Usage no npm install needed!

<script type="module">
  import ngryStore from 'https://cdn.skypack.dev/@ngry/store';
</script>

README

build unit-tests code-style GitHub release (latest by date) npm (scoped) Coveralls github

Installation

Install the package:

npm i @ngry/store

Optionally, install @ngry/rx for useful operators like ofType and toTask and handy testing tools for Observables:

npm i @ngry/rx

Usage example

Step 1: Declare the state interface(s):

import { TaskState } from '@ngry/rx';

interface Page<T> {
  page: number;
  size: number;
  content: T[];
}

interface Hero {
  id: number;
  nickname: string;
}

interface HeroListState {
  readonly page: number;
  readonly pageSize: number;
  readonly previewId?: number;
  readonly removing: TaskState<boolean>;
}

Step 2: Create custom store by extending Store<TState> class:

import { Observable, of } from 'rxjs';
import { delay, map, switchMap, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoryStateContainer, Store } from '@ngry/store';
import { TaskState, toTask } from '@ngry/rx';

@Injectable()
class HeroListStore extends Store<HeroListState> {
  readonly previewId$ = this.select(state => state.previewId);
  readonly removing$ = this.select(state => state.removing);
  readonly page$ = this.select(state => state.page);
  readonly pageSize$ = this.select(state => state.pageSize);
  readonly offset$ = this.select(
    this.page$,
    this.pageSize$,
    (page: number, size: number) => (page - 1) * size,
  );

  readonly data$: Observable<TaskState<Page<Hero>>> = this.produce(
    this.page$,
    this.pageSize$,
    args$ => args$.pipe(
      switchMap(([page, size]) => this.service.getList(page, size).pipe(
        toTask(),
      )),
    ),
  );

  readonly preview$: Observable<TaskState<Hero>> = this.produce(
    this.previewId$,
    (args$: Observable<[previewId: number | undefined]>) => args$.pipe(
      switchMap(([previewId]) => {
        if (previewId != null) {
          return this.service.getById(previewId).pipe(
            toTask(),
          );
        } else {
          return of(TaskState.initial<Hero>());
        }
      }),
    ),
  );

  readonly removed$: Observable<boolean> = this.produce(
    state$ => state$.pipe(
      map(state => state.removing.complete && (state.removing.result ?? false)),
    ),
  );

  constructor(private service: HeroService) {
    super(new MemoryStateContainer({
      page: 1,
      pageSize: 20,
      removing: TaskState.initial(),
    }));
  }

  readonly remove = this.method<[id: number]>(args$ => args$.pipe(
    switchMap(([state, id]) => this.service.remove(id).pipe(
      toTask(),
      tap(removing => this.setState({...state, removing})),
    )),
  ));

  readonly removeAll = this.method(args$ => args$.pipe(
    switchMap(() => this.service.removeAll().pipe(
      toTask(),
      tap(removing => this.patchState({removing})),
    )),
  ));

  setPage(page: number): void {
    this.patchState({page});
  }

  setPageSize(pageSize: number): void {
    this.patchState(() => ({pageSize}));
  }

  setPageBy(delta: number): void {
    this.patchState(state => ({page: state.page + delta}));
  }

  nextPage(): void {
    this.setState(state => ({
      ...state,
      page: state.page + 1,
    }));
  }

  setPreviewId(previewId: number | undefined): void {
    this.patchState({previewId});
  }

  reset(): void {
    this.resetState();
  }
}

Step 3: Use your new store wherever needed, for example in a component:

import { Component } from '@angular/core';

@Component({
  template: '...',
  providers: [
    HeroListStore,
  ],
})
class HeroListComponent {
  constructor(
    readonly store: HeroListStore,
  ) {
  }
}

Step 4: Test your new store:

import { ObservableSpy, TaskState } from '@ngry/rx';
import { TestBed } from '@angular/core/testing';

describe('HeroListStore', () => {
  let store: HeroListStore;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        HeroListStore,
      ],
    });

    store = TestBed.inject(HeroListStore);
  });

  describe('#offset

, () => {
    describe('when page number or page size changes', () => {
      let offsetSpy = new ObservableSpy(store.offset$);

      beforeEach(() => {
        store.setPage(10);
        store.setPageSize(10);
      });

      it('should emit updated value', () => {
        expect(offsetSpy.values).toEqual([0, 180, 90]);
      });
    });
  });

  describe('#remove', () => {
    let removingSpy = new ObservableSpy(store.removing$);
    let removedSpy = new ObservableSpy(store.removed$);

    beforeEach(() => {
      store.remove(10);
    });

    it('should delete an item', () => {
      expect(removingSpy.values).toEqual([
        TaskState.initial(),
        TaskState.pending(),
        TaskState.complete(true),
      ]);

      expect(removedSpy.values).toEqual([false, true]);
    });
  });
});

License

MIT