Reintroduce NgRx - ComponentStore vs Store (part 2)
Observable is pushed-based architecture. NgRx has two observable-based
implementation, Store
and ComponentStore
. Store
is more flexible and
scalable, but you need deal with multiple objects such actions and reducers,
selectors. ComponentStore
is also powerful but much simpler to use. It
involves less concepts and requires less coding.
All the source code of the post can be found here. The live demo is here
State in ComponentStore
Let’s implement the counter example using ComponentStore
first.
export interface CmpStoreCounter {
result: number;
}
@Injectable({
providedIn: "root",
})
export class CmpStoreCounterService extends ComponentStore<CmpStoreCounter> {
constructor() {
super({ result: 0 });
}
result$ = this.select((state) => state.result);
add = this.updater((state) => ({ ...state, result: state.result + 1 }));
}
@Component({
selector: "app-ngrx-cmp-store-counter",
template: `
{{ logId() }}
<h1>component store counter: {{ id }}</h1>
<h2>Result: {{ result$ | async }}</h2>
<button (click)="add()">Add</button>
`,
styles: [],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CmpStoreCounterComponent extends Debug {
constructor(private service: CmpStoreCounterService) {
super();
}
result$ = this.service.result$;
add() {
this.service.add();
}
}
To implement a ComponentStore
, we only need to use updater
to update the
state, and the ComponentStore
give your free observables for each member of
state in store. Consuming ComponentStore
in UI is very easy, as it has
everything that UI needs, the update method and the state observable. The
programming modal is very similar to the service implemented with
BehaviorSubject
. You can provide a component store as root service like the
example here, or you can provide it as component level service.
State in Store
Now let’s implement counter example using NgRx store.
Let’s ignore how we implement the state first, but focus on how to consume the state in component.
@Component({
selector: "app-store-counter",
template: `
{{ logId() }}
<h1>Store counter: {{ id }}</h1>
<h2>Result: {{ result$ | async }}</h2>
<button (click)="add()">Add</button>
`,
styles: [],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StoreCounterComponent extends Debug {
constructor(private store: Store) {
super();
}
result$ = this.store.select(selectCounterResult);
add() {
this.store.dispatch(actionAdd({ value: 1 }));
}
}
Unlike ComponentStore, where the UI only need to deal with one ComponentStore object, here the UI need to deal with 3 kinds of objects.
- store
- selector
- action
Steps to consume store
- Create observable with selectors (read state)
- Dispatch actions (update state or trigger side effect).
To get the state in observable, you call store.select(selector)
. To update
the state, you call store.dispatch(action(delta))
. You need to remember what
selector to use and what action to dispatch, which is not as easy as component
store.
The following is how I implement actions, reducers, and selectors.
//1. define actions
export const actionAdd = createAction(
"[counter] - add",
props<{ value: number }>()
);
//2. define reducer (how to update the store)
export interface CounterStoreRoot {
result: number;
}
export const counterReducer = createReducer(
{ result: 0 } as CounterStoreRoot,
on(actionAdd, (state, action) => {
return {
result: action.value + state.result,
};
})
);
const counterStorePath = "counter";
//3. define selectors (exposing state as observable)
export const selectCounterStore =
createFeatureSelector<CounterStoreRoot>(counterStorePath);
export const selectCounterResult = createSelector(
selectCounterStore,
(state) => state.result
);
//4. register reducer and effect
export const counterStoreModules = [
StoreModule.forFeature(counterStorePath, calculationterReducer),
];
@NgModule({
imports: [
counterStoreModules,
StoreModule.forRoot({}, {}),
EffectsModule.forRoot([]),
],
bootstrap: [AppComponent],
})
export class AppModule {}
The code is a bit messy, but all the reducer, selector, selectors are all very simple to implement
Steps to implement stores
- Define actions
- Define reducers / effects
- Define selectors
- Register reducers / effects.
Actions are free. They do not depend on reducers or anything. Selector is used to extract a state from store. Reducer is used to update a slice of the store. Both selectors and reducers are bound to a physical slice of the store. Reducers need to be registered to store to create state, and there are two way to do that.
Register reducer to root.
We need to add an entry in root object like below, everytime you register a new reducer.
@NgModule({
imports: [
StoreModule.forRoot({
'counter': counterReducer,
'path2', reducer2,
//...
}, {}),
EffectsModule.forRoot([]),
],
bootstrap: [AppComponent],
})
export class AppModule {}
Register reducer as feature module.
This method you don’t need to modify the forRoot method, you just need to add a new feature module. And it is cleaner.
export const counterStoreModules = [
StoreModule.forFeature(counterStorePath, calculationReducer),
];
@NgModule({
imports: [
counterStoreModules,
StoreModule.forRoot({}, {}),
EffectsModule.forRoot([]),
],
bootstrap: [AppComponent],
})
Why separate action from reducer in Store.
In the Store
case, there is the action(event), the reducer(handler). To update
store, you dispatch an action, then you respond to the action with handler. The
indirection seems to be very low efficiency. What benefit is that?
When the event and the event handler are separated, we can have many flexibilites. For example, we can have multiple handlers subscribe to a single event, and we can have one handler handle multiple events. Its many to many mapping.
But in the ComponentStore
case, one event (the method name) is bound to one
handler (updater). It is one to one mapping.
Think Reactively.
To take advantage of this separation, We need to change our programming mindset. We developer like to control and command. Using this mindset will not be fully take advantage of this flexibility.
If you like to control, you tend to use action as command. You will think like,
“Hey counter state, please execute the add command to update yourself”. You tend
to name the action '[counter] add'
. You event name follow the pattern
[state name] do something
. If you think like this, using ComponentStore
maybe be a better for you, because each update method map to its command name.
If you think reactively, you don’t want to command people to do something.
You act implicitly. If you use action as event, you tend to think, “Hi there
in case you are interested, my add button is clicked”. You will name your
action as '[Page] - add button clicked'
. If you think like this, you event
name follow the pattern "[event_source] what happened"
The following is the code of better counter component, which using reactive mindset.
export const addButtonClicked = createAction(
"[better counter component] - add button clicked",
props<{ value: number }>()
);
export const betterCounterReducer = createReducer(
{ result: 0 } as BetterCounterStoreRoot,
on(addButtonClicked, (state, action) => {
return {
result: action.value + state.result,
};
})
);
export class BetterStoreCounterComponent extends Debug {
constructor(private store: Store) {
super();
}
result$ = this.store.select(selectBetterCounterResult);
add() {
this.store.dispatch(addButtonClicked({ value: 1 }));
}
}
There is literally no technical difference in new code other than the event renaming. But when you read your source code, or you debug with redux developer tool, you can easily know where this action is triggered. In the following image, which event name is debug friendly?