Estate is a simple yet flexible state management library for Angular
- Simple
- Intuitive
- Type-safe
- Immutable
- Scalable
- Flexible
- Light-weight
Follows Occam's razor principle
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 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 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
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 constSynchronous 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})
}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$;
}
}Used as a select(...) and select$(...) selector identifier
export const AppSelectors = {
getIsLoading: 'Get loading state',
getActiveTodo: 'Get active todo',
getTodos: 'Get todo list'
} as constMethods 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
}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))
})
);
}
}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 |
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)
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>
