Skip to content

ng-estate/store

Repository files navigation

Estate


logo
Estate is a simple yet flexible state management library for Angular


Advantages:

  • Simple
  • Intuitive
  • Type-safe
  • Immutable
  • Scalable
  • Flexible
  • Light-weight

Follows Occam's razor principle


Documentation

flow diagram
«Brilliant» flow diagram

State

State is represented by TypeScript state model, that describes the shape of your state & store configuration object itself. It is better to declare state model using Immutable<State> generic type to force data immutability on a type level.

Usually it looks something like this:

export type ExampleState = Immutable<{
  user: User;
  isLoading: boolean;
}>;

const ExampleInitialState: ExampleState  = {
  user: {},
  isLoading: false
};

Based on a scope of providing, you would use either StoreRootConfig<State> or StoreConfig<State> as a store type:

export const ExampleStore: StoreConfig<ExampleState> = {
  id: 'Example',
  initialState: ExampleInitialState,
  // ...
};

Store module

Store module is responsible for providing dependencies.

It has following methods:

static forRoot<State>(config: StoreRootConfig<State> = {}): ModuleWithProviders<_StoreRootModule>

Mandatory store initialize method that is used in a "root" module in order to initialize store related (singleton) services. To see an example, refer to the "Setup" section below

static forChild<State>(config: StoreConfig<State>): ModuleWithProviders<_StoreChildModule>

Used by a lazy-loaded modules. To see an example, refer to the "Setup" section below

Note: If you want to provide dependencies for eagerly-loaded module, you have to manually initialize (push) store configuration object within module constructor, as in example below:

@NgModule({
  declarations: [
    ExampleComponent
  ],
  imports: [
    CommonModule,
  ]
})
export class ExampleModule {
  constructor(storeManager: StoreManager) {
    storeManager.registerStore(ExampleStore);
  }
}

This inconsistency happens due to Angular dependency resolution strategies, that may be different for lazy & eagerly-loaded modules

In a debug mode you might also need to pass Injector as its second parameter, like this:

...
constructor(storeManager: StoreManager, injector: Injector) {
    storeManager.registerStore(ExampleStore, injector);
}
...

static forFeature<State>(config: StoreConfig<State>): Array<Provider>

Used on a component level, if it needs individual Store instance. Example:

@Component({
  selector: 'app-example',
  templateUrl: './example.component.html',
  styleUrls: ['./example.component.css'],
  providers: [
    ...StoreModule.forFeature(ExampleStore),
    // ...
  ]
  // or solely
  // providers: StoreModule.forFeature(ExampleStore)
})
export class ExampleComponent {
  constructor(private readonly store: Store<ExampleState>) {
    // ...
  }
}

Store

Store offers simplistic API methods, both synchronous and asynchronous in their nature, such as:

select<Result, Payload = unknown>(selector: string, payload?: Payload): Result

Used for taking a snapshots of a current state, i.e. synchronous data retrieval

select$<Result, Payload = unknown>(selector: string, payload?: Payload): Observable<Result>

Used for an asynchronous subscription based data retrieval

dispatch<Payload>(action: string, payload?: Payload): void

Used for synchronous operations with state data. Asynchronous operations would be ignored for this type of dispatch

dispatch$<Result, Payload = unknown>(action: string, payload?: Payload): Observable<Result>

Used for operations which involve asynchronous programming. Results in observable that has to be subscribed to in order to bring async instructions into action

destroy(storeId: string): void

Allows to manually destroy store entity based on id

Actions

Used as a dispatch(...) and dispatch$(...) action identifier

export const AppActions = {
  fetchTodo: 'Fetch individual todo',
  fetchTodos: 'Fetch todo list',
  todosFetched: 'On todos fetch success'
} as const

Reducers

Synchronous state change caused by action dispatch

Note: In order to guarantee state property integrity, ReducerResult<...> type has to be specified

export const AppReducers: Reducers<AppState> = {
  [AppActions.fetchTodo]: (state): ReducerResult<AppState> => ({...state, activeTodo: null, isLoading: true}),
  [AppActions.fetchTodos]: (state): ReducerResult<AppState> => ({...state, isLoading: true}),
  [AppActions.todoFetched]: (state, todo: Todo): ReducerResult<AppState> => ({...state, todo, isLoading: false}),
  [AppActions.todosFetched]: (state, todos: Array<Todo>): ReducerResult<AppState> => ({...state, todos, isLoading: false})
}

Effects

Side effect of action dispatch

export const AppEffects: Effects<AppState> = {
  [AppActions.fetchTodo]: ({payload, dispatch, dispatch$, injector}: EffectOptions<AppState, number>): EffectResult<Todo> => {
    const todoService = injector.get(TodoService);
  
    const todo$ = todoService.getById(payload).pipe(
      tap((todo) => {
        dispatch(AppActions.todoFetched, todo);
      })
    );
  
    return todo$;
  },
  [AppActions.fetchTodos]: ({dispatch, injector}: EffectOptions<AppState>): EffectResult<Array<Todo>> => {
    const todoService = injector.get(TodoService);
    
    const todos$ = todoService.getAll().pipe(tap((todos) => {
      dispatch(AppActions.todosFetched, todos);
    }));
  
    return todos$;
  }
}

Selectors

Used as a select(...) and select$(...) selector identifier

export const AppSelectors = {
  getIsLoading: 'Get loading state',
  getActiveTodo: 'Get active todo',
  getTodos: 'Get todo list'
} as const

Getters

Methods defining what state data to return

export const AppGetters: Getters<AppState> = {
  [AppSelectors.getIsLoading]: (state) => state.isLoading,
  [AppSelectors.getActiveTodo]: (state) => state.activeTodo,
  [AppSelectors.getTodos]: (state) => state.todos
}

Setup

Initialize Store providing dependencies in app root:

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({
      id: 'App',
      initialState: AppInitialState,
      selectors: AppSelectors,
      getters: AppGetters,
      actions: AppActions,
      reducers: AppReducers,
      effects: AppEffects,
      config: {
        maxEffectDispatchTotalCalls: 1,
      }
    } as StoreRootConfig <AppState>)
],
providers: [],
  bootstrap
:
[AppComponent]
})

export class AppModule {
}

Inject Store where it's used:

@Component({
  selector: 'app-root',
  template: `
    <div *ngIf="isLoading$ | async">Loading...</div>

    <div *ngIf="todos$ | async as todos">
      <div *ngFor="let todo of todos;">
        {{ todo.title }}
      </div>
    </div>
  `
})
export class AppComponent {
  public readonly isLoading$: Observable<boolean>;
  public readonly todos$: Observable<Array<Todo>>;

  constructor(private readonly store: Store<AppState>) {
    this.isLoading$ = this.store.select$<Boolean>(AppSelectors.getIsLoading);
    this.todos$ = this.store.select$(AppSelectors.getTodos);
  }
}

Provide dependencies for child (lazy-loaded) modules:

@NgModule({
  declarations: [
    TodoComponent
  ],
  imports: [
    CommonModule,
    StoreModule.forChild({
      id: 'Todo',
      initialState: TodoInitialState,
      selectors: TodoSelectors,
      getters: TodoGetters,
      actions: TodoActions,
      reducers: TodoReducers,
      effects: TodoEffects
    } as StoreConfig<TodoState>)
  ],
  providers: [
    TodoMapper
  ]
})
export class TodoModule {}

Use within Component:

@Component({
  selector: 'app-todo',
  template: `
    <div *ngIf="isLoading$ | async">Loading...</div>
  
    <div *ngIf="todo$ | async as todo">
      <p>Id: {{ todo.id }}</p>
      <p>Title: {{ todo.title }}</p>
      <p>Description: {{todo.description}}</p>
      <p>Date: {{ todo.date }}</p>
    </div>
  `
})
export class TodoComponent {
  public readonly isLoading$: Observable<boolean>;
  public readonly todo$: Observable<Todo>;

  constructor(
    private readonly store: Store<AppState>, 
    private readonly route: ActivatedRoute
  ) {
    this.isLoading$ = this.store.select$(AppSelectors.getIsLoading);

    this.todo$ = this.route.params.pipe(
      switchMap((params: Params) => {
        return this.store.dispatch$<Todo>(AppActions.fetchTodo, Number(params.id))
      })
    );
  }
}

Configuration

There are 2 types of configuration interfaces: StoreRootConfig<StateType> and StoreConfig<StateType>, used by Store.forRoot(...) and Store.forChild(...) respectively. Global store configuration object is represented by internal _StoreConfig interface, which is a part of StoreRootConfig<StateType> and represented by the config property. This is the only difference between StoreRootConfig<StateType> and StoreConfig<StateType> Table below represents its characteristic

Property Description
id Defines store unique identifier that is used for prefixing Actions and Selectors. Has to be unique across whole application
initialState State blueprint and default value
selectors Used by getters as getter identifier. Has to be unique in a scope of its declaration object
getters Methods which is called against state in order to retrieve state data
actions Used by reducers and effects as trigger identifier. Acts as intermedium between reducer and effect. Has to be unique in a scope of its declaration object
reducers Performs synchronous state update
effects Performs synchronous & asynchronous operations on a reduced state
config.debug Tells store manager to use debug mode specific tools. This includes store logger & ngEstate console tools
If set to true, you might also need to provide Injector for eagerly-loaded modules on .registerStore(...)
config.freezeState Allows to make state data immutable programmatically using safeDeepFreeze util method, which is basically extended version of Object.freeze(). By default immutability is guaranteed only on a type level by the use of Immutable<T> type. If you want your data to keep its integrity and prevent accidental value override, set this property to true.
Note: as it's being recursive, it can impact performance dealing with complex data structures
config.freezePayload Allows to make payload argument immutable programmatically. The same rules are applied as for config.freezeState
config.maxEffectDispatchTotalCalls Limits dispatch(...) and dispatch$(...) call count per effect to a specific number
config.maxEffectDispatchCalls Limits dispatch(...) call count per effect to a specific number
config.maxEffectDispatch$Calls Limits dispatch$(...) call count per effect to a specific number

Debugging

For debugging purposes you can enable config.debug flag. This will reflect state changes in a console's debug tab & expose ngEstate global dev-tools object, which consists of:
actions - action list
dispatch - method for synchronous state operations (same as Store.dispatch(...))
dispatch$ - method for asynchronous state operations

dispatch$ is basically the same as Store.dispatch$(...) except an extra third argument - returnSource: boolean, which tells whether to return dispatch result observable & handle its subscription manually (might cause memory issues if doesn't have proper unsubscribe condition) or let store manager handle it itself by applying unsubscribe condition - .pipe(take(1))

You might want to configure it following way:

@NgModule({
  ...
  imports: [
    ...
    StoreModule.forRoot({
      ...AppStore,
      config: {
        debug: true, // ... your condition (e.g. env check, etc)
        ...AppStore.config,
      }
    })
  ]
})

Basic usage examples:

For synchronous state operations ngEstate.dispatch(ngEstate.actions.App.setIsLoading, true)

For asynchronous state operations ngEstate.dispatch$(ngEstate.actions.App.fetchAllTodos, {userId: 1}) (subscription is handled automatically) ngEstate.dispatch$(ngEstate.actions.App.fetchAllTodos, {userId: 1}, true).subscribe() (subscription is handled manually)

Public utilities API

ofAction(value: string | Array<string>): OperatorFunction<StoreEvent, StoreEvent>

Allows to react to dispatch events. Used in a pair with StoreManager.actionStream$ in order to filter StoreEvent's with certain action value(-s). Accepts both single action or array of actions. Basic usage: storeManager.actionStream$.pipe(ofAction(AppActions.fetchTodo)).subscribe((event: StoreEvent) => {...})

safeDeepFreeze<T>(value: T): Immutable<T>

Recursively applies Object.freeze(...) to a provided value, making it Immutable<T>

castImmutable<T>(value: T): Immutable<T>

Performs type cast, marking value as Immutable<T>. Equivalent of value as Immutable<T>

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors