Table of Contents
- Action Creator – with type property
- Action Creator – with props
- Action Creator – with a function
- Reducers
- Providing reducers
- How are reducers set up
- createReducer helper
- Store
- Selecting from the Store
- How does the memoization actually work
- State
- Meta-reducers
- Setting up meta-reducers
- Providing custom meta-reducers
- Injecting dependencies into a meta-reducer
- Using features
This piece emerged from my exploration of the source code, where I traced how the core components of @ngrx/store interconnect. Along the way, I uncovered numerous fascinating details about each element of the module, which I’ll walk through here. We’ll examine every entity thoroughly and clarify its place within the broader design.
Before we start, a quick overview of the principal actors:
- State: the data structure that holds the application’s internal data
- Store: the intermediary connecting data consumers to the state
- Actions: the catalyst for state transitions
- Reducers: the machinery that enacts changes on the state
- Meta-reducers: a mechanism for intercepting the action –> reducer flow
Let’s begin with actions.
Actions
Actions serve as directives for reducers and also form the foundation of effects. Typically, they are dispatched from the view layer (such as smart components or services) or from within effects.
Constructing actions
There are three distinct approaches for creating actions:
const action = createAction('[Entity] simple action');
action();
const action = createAction('[Entity] simple action', props<{ name: string, age: number, }>());
action({ name: 'andrei', age: 18 });
const action = createAction('action',(u: User, prefix: string) => ({ name: `${prefix}${u.name}` }) );
const u: User = { /* ... */ };
action(u, '@@@@');
Regardless of the method, the resulting function returns an object that includes at least this property: { type: T }.
Moreover, the type—the first argument to createAction—gets attached as a property of the created function. This becomes essential later when defining reducers.
function defineType<T extends string>(
type: T,
creator: Creator
): ActionCreator<T> {
return Object.defineProperty(creator, 'type', {
value: type,
writable: false,
});
}
TypeScript’s role
Now, let’s uncover the pivotal part TypeScript plays here. Have you ever wondered why the props() utility exists? Let’s dig in!
The createAction function provides three overloads:
export declare interface TypedAction<T extends string> extends Action {
readonly type: T;
}
export type ActionCreator<
T extends string = string,
C extends Creator = Creator
> = C & TypedAction<T>;
export function createAction<T extends string>(
type: T
): ActionCreator<T, () => TypedAction<T>>;
export function createAction<T extends string, P extends object>(
type: T,
config: Props<P> & NotAllowedCheck<P>
): ActionCreator<T, (props: P & NotAllowedCheck<P>) => P & TypedAction<T>>;
export function createAction<
T extends string,
P extends any[],
R extends object
>(
type: T,
creator: Creator<P, R> & NotAllowedCheck<R>
): FunctionWithParametersType<P, R & TypedAction<T>> & TypedAction<T>;
Consequently, the function’s implementation must incorporate several type guards to ensure type correctness.
ActionCreator<T, C> denotes a function of type C that carries a readonly attribute type of type T. This type can also serve to discriminate between union types.
Let’s explore each overload individually.
createAction with only a type parameter
const action = createAction('[Entity] simple action');
action(); // { type: [Entity] simple action }
This corresponds to the following overload:
export function createAction<T extends string>(
type: T
): ActionCreator<T, () => TypedAction<T>>;
From this snippet, we can infer that the return type is a function which yields an object featuring a type property.
The type guard that clarifies this is:
export function createAction<T extends string, C extends Creator>(
type: T,
config?: { _as: 'props' } | C
): ActionCreator<T> {
const as = config ? config._as : 'empty';
switch (as) {
case 'empty':
return defineType(type, () => ({ type }));
/* ... */
}
}
Here, defineType appends the type attribute to the function—in this case, () => ({ type }).
createAction with props
This pattern is useful when dispatching an action carrying data that the reducer needs (e.g., userActions.add({ name, age })).
const action = createAction('[Entity] simple action', props<{ name: string, age: number, }>());
action({ name: 'andrei', age: 18 });
What props<T>() accomplishes is returning an object with a fixed key (_as: 'props') and a key of type T, which facilitates type inference.
export function props<P extends object>(): Props<P> {
return { _as: 'props', _p: undefined! };
}
export interface Props<T> {
_as: 'props';
_p: T;
}
The overload looks like this:
export function createAction<T extends string, P extends object>(
type: T,
config: Props<P> & NotAllowedCheck<P>
): ActionCreator<T, (props: P & NotAllowedCheck<P>) => P & TypedAction<T>>;
config becomes an instance of props<P>(), enabling P to be inferred and applied within (props: P & NotAllowedCheck<P>) => P & TypedAction<T>>.
ActionCreator<T, (props: P & NotAllowedCheck<P>) => P & TypedAction<T>> represents a function that takes one argument—a single object—whose type is P (derived from props<P>()), and whose return type is an object containing all of P’s properties (P being an object) along with the type property (TypedAction<T>).
Here’s how createAction implements this:
export function createAction<T extends string, C extends Creator>(
type: T,
config?: { _as: 'props' } | C
): ActionCreator<T> {
if (typeof config === 'function') {
/* ... */
// `config._as` - returned from `props()`
const as = config ? config._as : 'empty';
switch (as) {
/* ... */
case 'props':
return defineType(type, (props: object) => ({
...props,
type,
}));
/* ... */
}
}
createAction with a function
This becomes valuable when you need to transform data before it hits the reducer, or when the action’s payload must be computed through more complex logic.
const action = createAction(
'action',
(u: User, prefix: string) => ({ name: `${prefix}${u.name}` })
);
const u: User = { /* ... */ };
action(u, '@@@@');
The overload for this case is:
export function createAction<
T extends string,
P extends any[],
R extends object
>(
type: T,
creator: Creator<P, R> & NotAllowedCheck<R>
): FunctionWithParametersType<P, R & TypedAction<T>> & TypedAction<T>;
Creator<P, R> is essentially a function that accepts a parameter of type P and outputs an object of type R. This setup lets us infer both P and R. NotAllowedCheck<R> ensures that creator isn’t an existing action or an array; it must be a function that takes arguments and returns an object representing the action’s data.
FunctionWithParametersType<P, R & TypedAction<T>> & TypedAction<T>; indicates that the return type must be a function with arguments of type P (inferred from Creator<P, R>), which returns an object of type R (similarly inferred), and also carries a type property.
Here’s what occurs within createAction:
export function createAction<T extends string, C extends Creator>(
type: T,
config?: { _as: 'props' } | C
): ActionCreator<T> {
if (typeof config === 'function') {
return defineType(type, (...args: any[]) => ({
// `config(...args)` will return an object
...config(...args),
type, // The `type` property is always returned
}));
}
/* ... */
}
Reducers
Reducers are pure functions that determine how the state changes in response to actions.
The interface defining a reducer's structure is shown below:
export interface ActionReducer<T, V extends Action = Action> {
(state: T | undefined, action: V): T;
}
A reducer accepts two arguments: the current state and the action that was just dispatched.
Ways to supply reducers
There are two distinct methods to make reducers available:
- a plain object where each value is a reducer built via
createReducer
StoreModule.forRoot({ foo: fooReducer, user: UserReducer })
Each property name in this object corresponds to a specific slice of the overall store state.
- an injection token
const REDUCERS_TOKEN = new InjectionToken('REDUCERS');
@NgModule({
imports: [
StoreModule.forRoot(REDUCERS_TOKEN)
],
providers: [
{ provide: REDUCERS_TOKEN, useValue: { foo: fooReducer } }
],
}) /* ... */
The setup process for reducers
Consider a scenario where reducers are supplied in the following manner:
StoreModule.forRoot({ entity: entityReducer })
When invoked, StoreModule.forRoot produces a ModuleWithProviders object that encompasses several providers, including these:
/* ... */
{
provide: _REDUCER_FACTORY,
useValue: config.reducerFactory
? config.reducerFactory
: combineReducers,
},
{
provide: REDUCER_FACTORY,
deps: [_REDUCER_FACTORY, _RESOLVED_META_REDUCERS],
useFactory: createReducerFactory,
},
/* ... */
Notice that unless a custom reducer factory is supplied, the default combineReducers function is employed—this will be examined shortly. The createReducerFactory parameter is primarily there to integrate meta-reducers.
Only the ReducerManager class injects the REDUCER_FACTORY token:
export class ReducerManager /* ... */ {
constructor(
@Inject(INITIAL_STATE) private initialState: any,
@Inject(INITIAL_REDUCERS) private reducers: ActionReducerMap<any, any>,
@Inject(REDUCER_FACTORY)
private reducerFactory: ActionReducerFactory<any, any>
) {
super(reducerFactory(reducers, initialState));
}
/* ... */
}
Upon injection, the createReducerFactory function runs immediately. Consequently, the reducerFactory property retains its output—a function that expects an object containing reducers (the reducers parameter) and, if provided, an initialState:
export function createReducerFactory<T, V extends Action = Action>(
reducerFactory: ActionReducerFactory<T, V>,
metaReducers?: MetaReducer<T, V>[]
): ActionReducerFactory<T, V> {
if (Array.isArray(metaReducers) && metaReducers.length > 0) {
(reducerFactory as any) = compose.apply(null, [
...metaReducers,
reducerFactory,
]);
}
// `ReducerManager.reducerFactory` will hold this function! - it is immediately invoked in the constructor
return (reducers: ActionReducerMap<T, V>, initialState?: InitialState<T>) => {
const reducer = reducerFactory(reducers);
return (state: T | undefined, action: V) => {
// This function is the value resulted from `super(reducerFactory(reducers, initialState));`(takes place inside `ReducerManager`'s constructor)
state = state === undefined ? (initialState as T) : state;
return reducer(state, action);
};
};
}
The line super(reducerFactory(reducers, initialState)) merges all individual reducers into a single object where each key aligns with a store slice:
export function combineReducers(
reducers: any,
initialState: any = {}
): ActionReducer<any, Action> {
const reducerKeys = Object.keys(reducers);
const finalReducers: any = {};
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i];
if (typeof reducers[key] === 'function') {
finalReducers[key] = reducers[key];
}
}
/*
Remember from the previous snippet: `const reducer = reducerFactory(reducers)`
Now, the `reducer` will be the below function.
*/
return function combination(state, action) {
state = state === undefined ? initialState : state;
let hasChanged = false;
const nextState: any = {};
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i];
const reducer: any = finalReducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
return hasChanged ? nextState : state;
};
}
This snippet also clarifies why immutability is crucial in the store. If a reducer returned the same object reference despite changing a property, the check nextStateForKey !== previousStateForKey would fail, and the UI would not detect the update.
The core logic boils down to this piece:
/* ... */
// The below function is the result of
// `@Inject(REDUCER_FACTORY) private reducerFactory: ActionReducerFactory<any, any>`
return (reducers: ActionReducerMap<T, V>, initialState?: InitialState<T>) => { // #Fn1
const reducer = reducerFactory(reducers); // <-
return (state: T | undefined, action: V) => { // #Fn2
state = state === undefined ? (initialState as T) : state;
// `reducer` = `combination` function; when called, will iterate over the existing reducers
// and will call them with the current `state` and `action`
return reducer(state, action);
};
};
super(reducerFactory(reducers, initialState)) invokes the previously mentioned reducerFactory , which results in all reducers being combined into one.
Once reducerFactory(reducers) runs and assigns the result to const reducer = ..., this reducer effectively takes over as the combination function. On each invocation, it loops through the reducers, calling each one with the supplied state and action.
The function wrapping this reducer is executed for every dispatched action, meaning the combination process (the Fn1 call) happens just once. If reducers are added or removed later, the reducer object is rebuilt accordingly (triggering Fn1 again).
The createReducer helper
To construct reducers that manage state transitions, the createReducer() utility is available.
It takes the initialState as its first parameter, followed by a variable number of on functions, whose types are derived from the initialState type.
The on functions serve as a substitute for the traditional switch statement. An on function can accept several action creators (output from [createAction](https://github.com/Andrei0872/my-dev-notes/blob/master/articles/ngrx/ngrx-store.md#creating-actions)) with the actual reducer as its final argument.
It yields an object shaped as { types: string[], reducer: ActionReducer<S> }, where types holds the action types from each provided creator, and reducer is a pure function that modifies state based on the action, following the signature (state: T | undefined, action: V): T;.
export interface On<S> {
reducer: ActionReducer<S>;
types: string[];
}
export interface OnReducer<S, C extends ActionCreator[]> {
(state: S, action: ActionType<C[number]>): S; // `ActionType` - Will infer the type of the action
}
export function on<C1 extends ActionCreator, S>(
creator1: C1,
reducer: OnReducer<S, [C1]>
): On<S>;
/* ... Overloads ... */
export function on(
...args: (ActionCreator | Function)[]
): { reducer: Function; types: string[] } {
const reducer = args.pop() as Function;
const types = args.reduce(
// `creator.type` is a property directly attached to the function so that
// it can be easily accessed(`createAction` is responsible for that)
(result, creator) => [...result, (creator as ActionCreator).type],
[] as string[]
);
return { reducer, types };
}
Internally, createReducer establishes a private Map<string, ActionReducer<S, A>> where keys are action type strings and values are the matching reducers. It then returns a function expecting a state and an action. Due to closure, this function retains access to the Map.
This returned function executes on every action dispatch. It locates the reducer via the action type, and if found, invokes it, potentially producing a fresh state.
export interface ActionReducer<T, V extends Action = Action> {
(state: T | undefined, action: V): T;
}
export function createReducer<S, A extends Action = Action>(
initialState: S,
...ons: On<S>[]
): ActionReducer<S, A> {
const map = new Map<string, ActionReducer<S, A>>();
for (let on of ons) {
for (let type of on.types) {
if (map.has(type)) {
const existingReducer = map.get(type) as ActionReducer<S, A>;
const newReducer: ActionReducer<S, A> = (state, action) =>
on.reducer(existingReducer(state, action), action);
map.set(type, newReducer);
} else {
map.set(type, on.reducer);
}
}
}
return function(state: S = initialState, action: A): S {
// This is the body of `_counterReducer` function from below
const reducer = map.get(action.type);
return reducer ? reducer(state, action) : state;
};
}
Take, for instance, a reducer defined like this:
const increment = createAction('increment');
const decrement = createAction('decrement');
const reset = createAction('reset');
const _counterReducer = createReducer(initialState,
on(increment, state => state + 1 /* reducer#1 */),
on(decrement, state => state - 1 /* reducer#2 */),
on(reset, state => 0 /* reducer#3 */),
);
export function counterReducer(state, action) {
return _counterReducer(state, action);
}
Its corresponding Map would be structured as follows:
{
key: "increment"
value: ƒ (state) // reducer#1
},
{
key: "decrement"
value: ƒ (state) // reducer#2
},
{
key: "reset"
value: ƒ (state) // reducer#3
}
The function returned by createReducer
return function(state: S = initialState, action: A): S {
const reducer = map.get(action.type);
return reducer ? reducer(state, action) : state;
};
gets invoked from within the combination function:
return function combination(state, action) {
for (let i = 0; i < finalReducerKeys.length; i++) {
/* ... */
const reducer: any = finalReducers[key];
const nextStateForKey = reducer(previousStateForKey, action); // <- Here!
/* ... */
}
/* ... */
};
An on function can attach a single reducer to multiple actions. Inside that reducer, discriminated unions allow for handling the correct state change depending on the action encountered.
const a1 = createAction('a1', props<{ name: string }>());
const a2 = createAction('a2', props<{ age: number }>());
const initialState = /* ... */;
const reducer = createReducer(
initialState,
on(a1, a2, (state, action) => {
if (action.type === 'a1') {
action.name
} else {
action.age
}
}),
)
The reason this works is outlined here:
export function on<C1 extends ActionCreator, C2 extends ActionCreator, S>(
creator1: C1,
creator2: C2,
reducer: OnReducer<S, [C1, C2]>
): On<S>;
// `C[number]` will result in a union
export interface OnReducer<S, C extends ActionCreator[]> {
(state: S, action: ActionType<C[number]>): S;
}
Additionally, the entire State type is inferred directly from the initialState provided:
export function createReducer<S, A extends Action = Action>(
initialState: S,
...ons: On<S>[]
): ActionReducer<S, A> { /* ... */ }
Another significant advantage of createReducer is its composability. The same action can be utilized across multiple reducers. This implies that the state from the nth on's reducer, given action a, is derived from the result of the n-1th on's reducer handling that same action a:
export function createReducer<S, A extends Action = Action>(
initialState: S,
...ons: On<S>[]
): ActionReducer<S, A> {
const map = new Map<string, ActionReducer<S, A>>();
for (let on of ons) {
for (let type of on.types) {
if (map.has(type)) {
// Getting the previous reducer(`n-1`)
const existingReducer = map.get(type) as ActionReducer<S, A>;
// The new reducer's state will be the result of the previous reducer's result
// n = n(n-1(state, action), action)
const newReducer: ActionReducer<S, A> = (state, action) =>
on.reducer(existingReducer(state, action), action);
map.set(type, newReducer);
} else { /* ... */ }
}
}
return function(state: S = initialState, action: A): S { /* ... */ };
}
Here's a practical illustration:
const a1 = createAction('a1');
const a2 = createAction('a2');
const reducer = createReducer(
0,
on(a1, state => state + 2 /* reducer1 */),
on(a1, state => state ** 2 /* reducer2 */),
on(a1, state => state * 10 /* reducer3 */)
);
console.log(reducer(undefined, a1)); // 40
// The above is similar to this:
reducer3(reducer2(reducer1(0)));
The Store Service
The Store class is among the core pieces of ngrx/store. It doesn't hold the data itself; rather, it serves as a channel connecting the component that needs data with the actual data storage location (the State class).
export class Store<T> extends Observable<T> implements Observer<Action> {
constructor(
state$: StateObservable,
private actionsObserver: ActionsSubject,
private reducerManager: ReducerManager
) {
super();
this.source = state$;
}
/* ... */
}
The code above shows that Store acts as a hot observable since its values originate externally from state$. Consequently, whenever state$ (the source) produces a value, Store propagates that value to all its subscribers. This mechanism relies on state$ inheriting from BehaviorSubject. When an observable uses this as its source, subscribing to it adds the new observer to the subscriber list managed by the BehaviorSubject. The following example demonstrates this behavior:
const s = new Subject();
class Custom extends Observable<any> {
constructor () {
super();
// By doing this, every time you do `customInstance.subscribe(subscriber)`,
// the subscriber wll be part of the subject's subscribers list
this.source = s;
}
}
const obs$ = new Custom();
// The subject has no subscribers at this point
s.next('no');
// The subject has one subscriber now
obs$.subscribe(console.log);
// `s.next()` -> sending values to the active subscribers
timer(1000)
.subscribe(() => s.next('john'));
timer(2000)
.subscribe(() => s.next('doe'));
Notice the role of ActionsSubject in this setup. When you call Store.dispatch, it enables you to push values into the stream of actions.
dispatch<V extends Action = Action>(
action: V /* ... type check here - skipped for brevity ... */
) {
this.actionsObserver.next(action);
}
You can view Store as both a dispatcher—because it offers Store.dispatch(action) to send out actions—and as a receiver of data, since subscribing to a Store instance via Store.subscribe() keeps you informed of state changes.
allows consumer ↔️ state communication
⬆️
|
|
----------- newState -----------
| | <------------------- | |
| | Store.source=$state | |
| | | | <---- storing data
| Store | Action | State |
| | --------------------> | |
| | Store.dispatch() | |
----------- -----------
| ⬆️
Action | | newState
| |
⬇️ |
-------------
| |
| Reducer | <---- state changes
| |
-------------
Therefore, the State class is where actions are combined with reducers. It executes the reducers with the existing state, and depending on the action, it produces a new state. This fresh state is then emitted through the Store, since the Store's source is the State itself.
export class State<T> extends BehaviorSubject<any> implements OnDestroy {
constructor(
actions$: ActionsSubject,
reducer$: ReducerObservable,
scannedActions: ScannedActionsSubject,
@Inject(INITIAL_STATE) initialState: any
) {
/* ... */
this.stateSubscription = stateAndAction$.subscribe(({ state, action }) => {
this.next(state); // Emitting the new state
scannedActions.next(action);
});
}
/* ... */
}
Think of Store as a mediator between the Model (where data is actually stored) and the Data Consumer:
Data Consumer -> Model: Store.dispatch()
Model -> Data Consumer: Store.subscribe()
Interestingly, Store isn't just an observable; it can also function as an observer, which is useful for intercepting actions from effects.
next(action: Action) {
this.actionsObserver.next(action);
}
error(err: any) {
this.actionsObserver.error(err);
}
complete() {
this.actionsObserver.complete();
}
This dual role is also handy when you aren't sure exactly which action you will dispatch or when you will need to dispatch it.
const actions$ = of(FooActions.add({ age: 18, name: 'andrei' }));
actions$.subscribe(this.store)
Retrieving Data from the Store
Because the Store's source is the State—the definitive data holder—extracting data and getting automatic updates is straightforward.
You have two ways to select data: using Store.select('path' | customSelector):
export class Store<T> /* ... */ {
select<Props = any, K = any>(
pathOrMapFn: ((state: T, props?: Props) => K) | string,
...paths: string[]
): Observable<any> {
return (select as any).call(null, pathOrMapFn, ...paths)(this);
}
}
export function select<T, Props, K>(
pathOrMapFn: ((state: T, props?: Props) => any) | string,
propsOrPath?: Props | string,
...paths: string[]
) {
return function selectOperator(source$: Observable<T>): Observable<K> {
let mapped$: Observable<any>;
/* ... Important logic here ... */
return mapped$.pipe(distinctUntilChanged());
};
}
or by calling Store.pipe(select('path') | select(customSelector)). In both cases, the select function is involved, and the outcome is an observable.
Suppose your state adheres to this interface:
interface AppState { foo: Foo; }
interface Foo {
fooUsers: User[];
prop1: string;
prop2: number;
}
interface User { name: string; age: number; }
Injection of the store would be done like this:
export class SmartComponent {
constructor (private store: Store<AppState>) { }
}
There are several strategies for pulling data from the store.
Using a path of string keys to specify the desired slice
this.store.select('foo', 'fooUsers', /* ... */)
.subscribe(console.log)
The select function comes with several overloads:
export function select<
T,
a extends keyof T,
b extends keyof T[a],
c extends keyof T[a][b],
d extends keyof T[a][b][c],
e extends keyof T[a][b][c][d]
>(
key1: a,
key2: b,
key3: c,
key4: d,
key5: e
): (source$: Observable<T>) => Observable<T[a][b][c][d][e]>;
Here, T refers to the generic type given to Store: export class Store<T> extends Observable<T>. In this scenario, we have AppState. The key foo has to be a valid property of AppState (which is T), and fooUsers must be a property of AppState['foo'], continuing in this fashion for deeper paths.
Internally, this path-based selection is executed using the pluck operator, which offers a declarative method for extracting object properties:
export function select<T, Props, K>(
pathOrMapFn: ((state: T, props?: Props) => any) | string,
propsOrPath?: Props | string,
...paths: string[]
) {
return function selectOperator(source$: Observable<T>): Observable<K> {
let mapped$: Observable<any>;
if (typeof pathOrMapFn === 'string') {
const pathSlices = [<string>propsOrPath, ...paths].filter(Boolean);
mapped$ = source$.pipe(pluck(pathOrMapFn, ...pathSlices));
}
/* ... */
}
}
Providing a custom mapping operator
export function select<T, Props, K>(
mapFn: (state: T, props: Props) => K,
props?: Props
): (source$: Observable<T>) => Observable<K>;
This method is akin to the previous one, but rather than listing properties, you supply a custom operator and optionally a second argument called props. The props object can contain data not found in the store, which you can use to modify the state's shape.
A key advantage of this technique is its compatibility with custom selectors generated by the createSelector() function (details on createSelector are in a later section).
The result of createSelector is a MemoizedSelector or MemoizedSelectorWithProps, both of which inherit from the fundamental Selector type.
export function createSelector(
...input: any[]
): MemoizedSelector<any, any> | MemoizedSelectorWithProps<any, any, any> { /* ... */ }
export interface MemoizedSelector<
State,
Result,
ProjectorFn = DefaultProjectorFn<Result>
> extends Selector<State, Result> {
release(): void;
/* ... */
}
In simpler terms, it outputs a selector—a function that takes a state and returns something derived from it—or a selector with props that also accepts additional props.
Understanding this is crucial because a custom selector is applied using the map operator:
export function select<T, Props, K>(
pathOrMapFn: ((state: T, props?: Props) => any) | string, // <- Complies with `MemoizedSelector` | `MemoizedSelectorWithProps`
propsOrPath?: Props | string,
...paths: string[]
) {
return function selectOperator(source$: Observable<T>): Observable<K> {
let mapped$: Observable<any>;
/* ... */
if (typeof pathOrMapFn === 'function') {
mapped$ = source$.pipe(
map(source => pathOrMapFn(source, <Props>propsOrPath))
);
}
/* ... */
};
}
See this in action with an example:
const state = {
todos: [
{ id: 1, name: 't1', done: true },
{ id: 2, name: 't2', done: true },
{ id: 3, name: 't3', done: false },
],
filterStatus: true,
};
const completedTodosSelector = createSelector(
(s: typeof state) => s.todos,
(s: typeof state) => s.filterStatus,
(todos, crtFilterStatus) => todos.filter(t => t.done === crtFilterStatus)
);
const store$ = of(state);
store$.pipe(
select(completedTodosSelector)
).subscribe(console.log);
Employing custom selectors
For more control, you can utilize the createSelector function. It accepts several selectors and, as its final argument, a projection function. The selectors pick out specific parts of the state, while the projection function determines the structure of the resulting value based on those selected parts. This projected value then flows into the stream for subscribers.
This is heavily reliant on pure functions. Since selectors must be pure, memoization is possible, avoiding redundant computations when inputs remain the same.
Here's a typical selector:
export type Selector<T, V> = (state: T) => V;
Alternatively, it might accept a props object containing non-store data that could impact the final value's shape:
export type SelectorWithProps<State, Props, Result> = (
state: State,
props: Props
) => Result;
You'll see that the selector above doesn't inherently suggest any memoization capability.
In contrast, a selector with memoization—like one created by createSelector—has this structure:
export interface MemoizedSelector<
State,
Result,
ProjectorFn = DefaultProjectorFn<Result>
> extends Selector<State, Result> {
release(): void;
projector: ProjectorFn;
setResult: (result?: Result) => void;
clearResult: () => void;
}
export type DefaultProjectorFn<T> = (...args: any[]) => T;
- The
projectoris the projection function mentioned earlier; it calculates the data's shape using the selectors. - The
release()method clears the memoized value from memory.
Additionally, there's MemoizedSelectorWithProps<State, Props, Result>, which extends SelectorWithProps but shares the same methods as MemoizedSelector.
export function createSelector(
...input: any[]
): MemoizedSelector<any, any> | MemoizedSelectorWithProps<any, any, any> {
return createSelectorFactory(defaultMemoize)(...input);
}
defaultMemoize accepts a projection function and wraps it to enable memoization:
export function defaultMemoize(
projectionFn: AnyFn,
isArgumentsEqual = isEqualCheck,
isResultEqual = isEqualCheck
): MemoizedProjection {
let lastArguments: null | IArguments = null;
let lastResult: any = null;
let overrideResult: any;
// Release value from memory
function reset() {
lastArguments = null;
lastResult = null;
}
function setResult(result: any = undefined) { overrideResult = { result }; }
function clearResult() { overrideResult = undefined; }
function memoized(): any {
if (overrideResult !== undefined) {
return overrideResult.result;
}
// First time the function is invoked
if (!lastArguments) {
// Call the projection function with the provided arguments
lastResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
return lastResult;
}
// If the arguments are not different than the previous ones
// there is no need to re-compute the results
if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
return lastResult;
}
// If we reached this point, it means the arguments were different
// which requires a new computation of the result
const newResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
if (isResultEqual(lastResult, newResult)) {
return lastResult;
}
lastResult = newResult;
return newResult;
}
return { memoized, reset, setResult, clearResult };
}
Memoization is achieved inside the memoized function. It's intentionally declared as a classic function rather than an arrow function to access the special arguments variable, which arrow functions don't possess!
Take this example:
function isEqualCheck(a: any, b: any): boolean {
return a === b;
}
function sum (a, b) { return a + b; };
const memoizedSum = defaultMemoize(sum, isEqualCheck, isEqualCheck);
/*
`sum` is executed
if (!lastArguments) { // <-- `lastArguments = null`
// Call the projection function with the provided arguments
lastResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
return lastResult;
}
*/
memoizedSum.memoized(1, 3);
/*
`sum` will not be executed again as it would be called with the same parameters
if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
return lastResult;
}
*/
memoizedSum.memoized(1, 3);
defaultMemoize is a fundamental part of createSelector and is where memoization occurs. However, with createSelector, memoization can happen at two distinct levels:
- At the state level, when the same state is provided to the selector.
const incomingState = {
user: {
hobbies: [ {name: 'a', recent: true}, { name: 'b', recent: false } ],
},
otherProperty: 'foo',
};
const userSelector = (s: typeof incomingState) => s.user
const userRecentHobbiesSelector = createSelector(
(u: typeof incomingState.user) => u.hobbies, // Selector
hobbies => hobbies.filter(h => h.recent), // Projection Function
);
// Similar to `this.store.pipe(select(/* ... */))`
merge(
of(incomingState),
// Receiving an update sometime in the future
of({ ...incomingState, otherProperty: 'bar' }).pipe(delay(500))
)
.pipe(
pluck('user'),
select(userRecentHobbiesSelector),
)
.subscribe(console.log)
In the example above, the console log appears only once. The reason is that on the second data arrival, userRecentHobbiesSelector checks if the new data differs from the previous. If it matches, it returns the cached (previous) value. This also implies userRecentHobbiesSelector's projection function runs only once.
- At the projection function level, when it's invoked with identical selector results.
Even if the overall state changes due to unrelated updates, the values returned by individual selectors might remain unchanged.
A selector built with createSelector can leverage its memoized value when a state slice irrelevant to its projection function gets updated.
interface User { name: string; age: number; isOk: boolean; }
interface State {
users: User[];
shouldShow: boolean;
notRelevantProperty: string;
}
const usersSelector = (s: State) => s.users;
const userProjectionFn = (users: User[]) => {
return users.filter(u => u.isOk);
};
const okUsersSelector = createSelector(
usersSelector,
userProjectionFn,
);
let dummyState: State = {
shouldShow: true,
users: [
{ name: 'a', age: 1, isOk: true },
{ name: 'b', age: 2, isOk: false },
{ name: 'c', age: 3, isOk: false },
{ name: 'd', age: 4, isOk: true },
],
notRelevantProperty: 'not relevant'
};
// First time the selector is used with this state object
// The returned value will be memoized
console.log(okUsersSelector(dummyState));
// Although the `dummyState` object changed its reference
// `dummyState.users` did not, meaning that `userProjectionFn` should use the memoized value
// because `usersSelector` will return the same `users` object
dummyState = {
...dummyState,
notRelevantProperty: 'not relevant - updated!',
};
console.log(okUsersSelector(dummyState));
This scenario occurs because projection functions often contain intricate logic, whereas a simple selector should just retrieve a property's value—an operation that isn't costly.
All these capabilities are combined in the createSelectorFactory:
export function createSelector(
...input: any[]
): MemoizedSelector<any, any> | MemoizedSelectorWithProps<any, any, any> {
return createSelectorFactory(defaultMemoize)(...input); // `input` - the sequence of selectors followed by the projection function
}
export function createSelectorFactory(
memoize: MemoizeFn,
options: SelectorFactoryConfig<any, any> = {
stateFn: defaultStateFn,
}
) {
return function(
...input: any[]
): MemoizedSelector<any, any> | MemoizedSelectorWithProps<any, any, any> {
let args = input;
if (Array.isArray(args[0])) {
const [head, ...tail] = args;
args = [...head, ...tail];
}
const selectors = args.slice(0, args.length - 1);
// The projection function is always the last argument provided
const projector = args[args.length - 1];
// `createSelector()` allows for composability
// In `createSelector()` you can use selectors resulted from `createSelector()` as well
const memoizedSelectors = selectors.filter(
(selector: any) =>
selector.release && typeof selector.release === 'function'
);
// Memoizing the projector
// If the selectors's return values are not different
// There is no need to re-run the projector function
// which might contain expensive logic
// In this case, `memoize === `defaultMemoize`
const memoizedProjector = memoize(function(...selectors: any[]) {
return projector.apply(null, selectors);
});
const memoizedState = defaultMemoize(function(state: any, props: any) {
return options.stateFn.apply(null, [
state,
selectors,
props,
memoizedProjector,
]);
});
// Releasing the value from memory
function release() {
memoizedState.reset();
memoizedProjector.reset();
// Releasing the selectors that were created by `createSelector()`
memoizedSelectors.forEach(selector => selector.release());
}
return Object.assign(memoizedState.memoized, {
release,
projector: memoizedProjector.memoized,
setResult: memoizedState.setResult,
clearResult: memoizedState.clearResult,
});
};
}
options.stateFn corresponds to defaultStateFn
if (props === undefined) {
const args = (<Selector<any, any>[]>selectors).map(fn => fn(state));
return memoizedProjector.memoized.apply(null, args);
}
// `props` - available in each provided selector as the second argument
const args = (<SelectorWithProps<any, any, any>[]>selectors).map(fn =>
fn(state, props)
);
// `props` - available in the projector as well
return memoizedProjector.memoized.apply(null, [...args, props]);
This is where the selectors get executed. The memoizedProjector.memoized function checks whether the arguments (the selectors' outputs) have changed; if not, it skips the projector and returns the cached result.
Notice that the function returned by createSelector() accepts two parameters: state and props. The props parameter can hold arbitrary data, not necessarily part of the store, which can still shape the projector's result.
const incomingState = {
user: {
hobbies: [ {name: 'a', recent: true}, { name: 'b', recent: false } ],
},
otherProperty: 'foo',
};
const userSelector = (s: typeof incomingState) => s.user
const userRecentHobbiesSelector = createSelector(
(u: typeof incomingState.user, props) => (console.log('props', props),u.hobbies),
(hobbies, props) => hobbies.filter(h => h.recent).map(h => `${props.prefix}${h.name}${props.suffix}`),
);
const props = {
prefix: '@@@@@',
suffix: '______',
};
merge(
of(incomingState),
of({ ...incomingState, otherProperty: 'bar' }).pipe(delay(500))
)
.pipe(
pluck('user'),
select(userRecentHobbiesSelector, props),
)
.subscribe(console.log)
Both snippets above confirm that props are accessible in the selectors and the projection function.
The select function used here is the same one employed in Store.select:
/* ... Inside `select` ... */
if (typeof pathOrMapFn === 'string') {
const pathSlices = [<string>propsOrPath, ...paths].filter(Boolean);
mapped$ = source$.pipe(pluck(pathOrMapFn, ...pathSlices));
} else if (typeof pathOrMapFn === 'function') {
mapped$ = source$.pipe(
map(source => pathOrMapFn(source, <Props>propsOrPath))
);
}
The Inner Workings of Memoization
To grasp the memoization process fully, we need to examine its core mechanism:
// createSelectorFactory's returned function body: createSelector(...inputs) { return createSelectorFactory(defaultMemoize)(...input); }
let args = input;
const selectors = args.slice(0, args.length - 1);
const projector = args[args.length - 1];
const memoizedSelectors = selectors.filter(
(selector: any) =>
selector.release && typeof selector.release === 'function'
);
// By default, `memoize === defaultMemoize`
const memoizedProjector = memoize(function(...selectors: any[]) {
return projector.apply(null, selectors);
});
const memoizedState = defaultMemoize(function(state: any, props: any) {
return options.stateFn.apply(null, [
state,
selectors,
props,
memoizedProjector,
]);
});
function release() {
memoizedState.reset();
memoizedProjector.reset();
memoizedSelectors.forEach(selector => selector.release());
}
return Object.assign(memoizedState.memoized, {
release,
projector: memoizedProjector.memoized,
setResult: memoizedState.setResult,
clearResult: memoizedState.clearResult,
});
This returns a function (memoizedState.memoized) callable with two arguments: state and props. This function is what createSelector() produces.
When memoizedState.memoized is invoked, it compares the current arguments with the previous ones. If a change is detected, it calls the callback passed to defaultMemoize:
export function defaultMemoize(projectionFn: AnyFn, /* ... */): MemoizedProjection { /* ... */ }
export type MemoizedProjection = {
memoized: AnyFn; // <-- Here is where the memoization happens
reset: () => void;
setResult: (result?: any) => void;
clearResult: () => void;
};
function memoized(): any {
if (overrideResult !== undefined) {
return overrideResult.result;
}
if (!lastArguments) {
lastResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
return lastResult;
}
if (!isArgumentsChanged(arguments, lastArguments, isArgumentsEqual)) {
return lastResult;
}
const newResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
if (isResultEqual(lastResult, newResult)) {
return lastResult;
}
lastResult = newResult;
return newResult;
}
For memoizedState, the projectionFn is:
// #1
function(state: any, props: any) {
return options.stateFn.apply(null, [
state,
selectors,
props,
memoizedProjector,
]);
}
Whereas for memoizedProject, it's:
// #2
function(...selectors: any[]) {
return projector.apply(null, selectors);
}
Let's walk through an example:
const state = {
status: 'ok',
actions: [ {name:'a1', status: 'ok'}, {name:'a2', status: 'denied'} ],
};
const actionsOfCrtStatusSelector = createSelector(
s => s.status,
s => s.actions,
(status, actions) => actions.filter(a => a.status === status),
);
// `actionsOfCrtStatusSelector` = `memoizedState.memoized`
actionsOfCrtStatusSelector(state);
Here's what unfolds when the selector is called with a state:
actionsOfCrtStatusSelector(state)is the same asmemoizedState.memoized(state).- Since it's the initial call,
memoizedState.memoizedsees there's no previous argument to compare with, so it executes this part of its code:
if (!lastArguments) {
// Call the function and memoize its result
lastResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
return lastResult;
}
In this case, projectionFn is #1 (from above). When invoked, it triggers options.stateFn:
This is where all the selectors are executed:
memoizedProjector.memoizedgets called with the selectors' outputs (and possibly apropsobject). Since it's also a first-time call, it runs its own projection function (#2):
if (!lastArguments) {
// Call the function and memoize its result
lastResult = projectionFn.apply(null, arguments as any);
lastArguments = arguments;
return lastResult;
}
// `projectionFn` from above
function(...selectors: any[]) {
return projector.apply(null, selectors);
}
// `projector`
(status, actions) => actions.filter(a => a.status === status),
Note: Although arrow functions lack their own this and arguments, you can still pass arguments using call(), bind(), or apply().
Here's the sequence for this scenario:
memoizedProjector = memoize(/* #2 */function(...selectors: any[]) {
return projector.apply(null, selectors);
});
memoizedState = defaultMemoize(/* #1 */function(state: any, props: any) {
return options.stateFn.apply(null, [
state,
selectors,
props,
memoizedProjector,
]);
});
memoizedState.memoized(state) ---compare crtArgs with prevArgs---> #1(state) -> invoke selectors with the given `state` ----selectorResults---> memoizedProjector(selectorResults) ---compare crtArgs with prevArgs---> #2(selectorResults)
After the first call, memoizedState.memoized(state) holds the output of #2(selectorResults).
For following calls, the path isn't always identical. For instance, if the state reference is unchanged, execution stops immediately, since the previous arguments (prevArgs) match the current ones (crtArgs):
memoizedState.memoized(state) ---compare crtArgs with prevArgs---> prevArgs
This underscores why immutability is crucial.
Suppose you have a custom selector built with createSelector(), relying on userSelector which reads from feat.users. If you add a new user without creating a fresh array reference, the projection function for userSelector will return the cached result because the reference hasn't changed, even though the array's content has been modified.
State Management Core
Beyond its other characteristics, this is where the application data lives and is maintained.
constructor(
actions$: ActionsSubject,
reducer$: ReducerObservable,
scannedActions: ScannedActionsSubject,
@Inject(INITIAL_STATE) initialState: any
) { /* ... */ }
actions$: aBehaviorSubjectthat fires on each dispatched action (e.g.,store.dispatch(newAction()))reducer$: aBehaviorSubjectholding functions; when invoked, these iterate through every registered reducer and execute them against the current state and the triggering actionscannedActions: communicates to other components (such aseffects) that an action has taken place
These parameters lack access modifiers, meaning the bulk of the work occurs within the constructor:
constructor (/* ... */) {
super(initialState);
const actionsOnQueue$: Observable<Action> = actions$.pipe(
observeOn(queueScheduler)
);
const withLatestReducer$: Observable<
[Action, ActionReducer<any, Action>]
> = actionsOnQueue$.pipe(withLatestFrom(reducer$));
const seed: StateActionPair<T> = { state: initialState };
const stateAndAction$: Observable<{
state: any;
action?: Action;
}> = withLatestReducer$.pipe(
scan<[Action, ActionReducer<T, Action>], StateActionPair<T>>(
reduceState,
seed
)
);
this.stateSubscription = stateAndAction$.subscribe(({ state, action }) => {
this.next(state);
scannedActions.next(action);
});
}
Here, incoming actions are captured and applied to the existing reducers. Once reducers process the new action, the resulting state is forwarded to consumers. The Store entity serves as that consumer, acting as an intermediary between the requestor (e.g., a component or service) and the State (the model holding the data). This is evident from the Store class line: this.source = state$;.
const withLatestReducer$: Observable<
[Action, ActionReducer<any, Action>]
> = actionsOnQueue$.pipe(withLatestFrom(reducer$));
This ensures that even if actionsOnQueue$ emits, no values propagate downstream unless reducer$ has also emitted. When both fire, values only pass through if actionsOnQueue$ is the one emitting again. Consequently, if reducers are added or removed later, each fresh action gets applied to the most current set of reducers.
-A---A--A--A-----A--> actionsOnQueue$
/ / /
| / /
------R------R------> reducer$
const seed: StateActionPair<T> = { state: initialState };
const stateAndAction$: Observable<{
state: any;
action?: Action;
}> = withLatestReducer$.pipe(
scan<[Action, ActionReducer<T, Action>], StateActionPair<T>>(
reduceState,
seed
)
);
export function reduceState<T, V extends Action = Action>(
stateActionPair: StateActionPair<T, V> = { state: undefined },
[action, reducer]: [V, ActionReducer<T, V>]
): StateActionPair<T, V> {
const { state } = stateActionPair;
return { state: reducer(state, action), action };
}
When reducer gets called, it cycles through the supplied reducers, invoking each with the existing state and the present action. Ultimately, it produces a new state that gets pushed into the stream:
this.stateSubscription = stateAndAction$.subscribe(({ state, action }) => {
this.next(state);
scannedActions.next(action); // Send the action to the effects
});
As noted earlier, this stream serves as the Store's source, enabling data consumers to stay informed about state changes.
Meta-reducers
In essence, meta-reducers are functions that accept a reducer and return another reducer. Much like interceptors wrapping HTTP requests, they can introduce logic both before and after a reducer executes.
Configuring meta-reducers
export class StoreModule {
static forRoot(
reducers,
config: RootStoreConfig<any, any> = {}
): ModuleWithProviders<StoreRootModule> {
return {
ngModule: StoreRootModule,
providers: [
/* ... */
{
provide: USER_PROVIDED_META_REDUCERS,
useValue: config.metaReducers ? config.metaReducers : [],
},
{
provide: _RESOLVED_META_REDUCERS,
deps: [META_REDUCERS, USER_PROVIDED_META_REDUCERS],
useFactory: _concatMetaReducers,
},
{
provide: REDUCER_FACTORY,
deps: [_REDUCER_FACTORY, _RESOLVED_META_REDUCERS],
useFactory: createReducerFactory,
},
/* ... */
]
}
}
}
When _RESOLVED_META_REDUCERS is injected into createReducerFactory, it appears as an array formed by combining built-in meta-reducers with any custom ones.
Three built-in meta-reducers exist: immutabilityCheckMetaReducer, serializationCheckMetaReducer, and inNgZoneAssertMetaReducer.
createReducerFactory produces a function called with two parameters: reducers and initialState. Early in the app's lifecycle, this function gets invoked with the arguments from StoreModule.forRoot({ reducers, }, { initialState }). Upon invocation, it constructs a chain (like a linked list) of meta-reducers, with the original reducer at the end. This arrangement lets each meta-reducer add behavior around the reducer's execution.
The function is returned because createReducerFactory is invoked when REDUCER_FACTORY is injected into the ReducerManager class. ReducerManager maintains reducers as features are introduced or removed, so when a feature arrives with its reducer, ReducerManager merges it with the existing ones.
addReducers(reducers: { [key: string]: ActionReducer<any, any> }) {
this.reducers = { ...this.reducers, ...reducers };
this.updateReducers(Object.keys(reducers));
}
Following that, it reconstructs the chain to apply meta-reducers correctly:
private updateReducers(featureKeys: string[]) {
this.next(this.reducerFactory(this.reducers, this.initialState)); // <- re-create the chain
this.dispatcher.next(<Action>{
type: UPDATE,
features: featureKeys,
});
}
export function createReducerFactory<T, V extends Action = Action>(
reducerFactory: ActionReducerFactory<T, V>,
metaReducers?: MetaReducer<T, V>[]
): ActionReducerFactory<T, V> {
// Setting up the `chain` - not created yet!
if (Array.isArray(metaReducers) && metaReducers.length > 0) {
(reducerFactory as any) = compose.apply(null, [
...metaReducers,
reducerFactory,
]);
}
return (reducers: ActionReducerMap<T, V>, initialState?: InitialState<T>) => {
const reducer = reducerFactory(reducers); // <- chain created
return (state: T | undefined, action: V) => {
state = state === undefined ? (initialState as T) : state;
return reducer(state, action);
};
};
}
The core lies in compose:
export function compose(...functions: any[]) {
return function(arg: any) {
if (functions.length === 0) {
return arg;
}
const last = functions[functions.length - 1];
const rest = functions.slice(0, -1);
return rest.reduceRight((composed, fn) => fn(composed), last(arg));
};
}
Here, functions represents an array of meta-reducers plus the function that merges reducers into a single object, while arg is the reducers set to be combined.
This can be pictured as follows:
// m-r -> meta-reducer
const myMetaReducer = (reducer) => (state, action) => {
/* Logic before reducer's invocation */
const result = reducer(state, action); // Invoke the reducer -> will return the new state
/* Logic after reducer's invocation */
return result; // Return it so other meta-reducers can access the new produced state
}
rest.reduceRight((composed, fn) => fn(composed), last(arg)); // <- `last(args)` will create the reducers object
|
|
⬇️
---------- reducer() ---------- reducer() -------------
| |--------------->| |--------------->| |
| m-r1 | | m-r2 | | reducer | <- // new state is produced
| |<---------------| |<---------------| |
---------- newState ---------- newState -------------
|
| // returned reducer; when called, it will in turn call the reducer received as an argument;
| // that argument 'points' to the previous reducer in the chain
⬇️
reducer(state, action)
Adding custom meta-reducers
With the previous understanding, we can now look at implementing custom meta-reducers.
export class StoreModule {
static forRoot(
reducers,
config: RootStoreConfig<any, any> = {}
): ModuleWithProviders<StoreRootModule> {
return {
ngModule: StoreRootModule,
providers: [
/* ... */
{
provide: USER_PROVIDED_META_REDUCERS,
useValue: config.metaReducers ? config.metaReducers : [],
},
{
provide: _RESOLVED_META_REDUCERS,
deps: [META_REDUCERS, USER_PROVIDED_META_REDUCERS],
useFactory: _concatMetaReducers,
},
/* ... */
]
}
}
}
/* ... */
export function _concatMetaReducers(
metaReducers: MetaReducer[],
userProvidedMetaReducers: MetaReducer[]
): MetaReducer[] {
return metaReducers.concat(userProvidedMetaReducers);
}
config.metaReducers
RootStoreConfig (shown above) builds upon StoreConfig:
export interface StoreConfig<T, V extends Action = Action> {
initialState?: InitialState<T>;
reducerFactory?: ActionReducerFactory<T, V>;
metaReducers?: MetaReducer<T, V>[];
}
Thus, a custom meta-reducer can be supplied like this:
StoreModule.forRoot(
reducersMap,
{ metaReducers, }
)
where metaReducers is an array of MetaReducer:
const myMetaReducer: MetaReducer = (reducer: ActionReducer<any, any>) => {
return (state, action) => {
console.log('before', action, state);
const result = reducer(state, action);
console.log('after', result);
return result;
}
}
export const metaReducers: MetaReducer[] = [myMetaReducer];
Injecting dependencies into a meta-reducer
At times, you may need to bring dependencies into your meta-reducers. The META_REDUCER multi-provider token facilitates this.
By registering the meta-reducer as a factory provider via META_REDUCER, dependencies can be injected.
For instance, consider this setup:
export const metaReducerWithDepFactory: (d: any) => MetaReducer =
(logger: LogService) => reducer => (state, action) => {
console.log('meta reducer with dep!', logger, action)
return reducer(state, action);
}
which gets registered as:
{
provide: META_REDUCERS,
multi: true,
useFactory: metaReducerWithDepFactory,
deps: [LogService]
}
You can experiment with this example here.
Additionally, for a clearer picture of the structure, place breakpoints in your ng-run tab at these spots:
foo.meta-reducer.ts: line 5utils.ts: line 32 -> thecombination(state, action)function, where the combined reducers are iterated and executedfoo.meta-reducer.ts: line 7
Leveraging Features
Introducing a feature module into a root module (which holds all reducers) is akin to placing a separate slice of cake back onto its original plate. The plate represents the root module, and the slice is the feature. This maintains a single source of truth (the plate), but each slice (feature module) retains its own embellishments (meta-reducers, reducers).
Registering feature modules
To register a feature, you use:
Store.forFeature(featureName, reducer: ActionReducerMap | ActionReducer, config)
where reducer is either a map of reducers (ActionReducerMap) or a single ActionReducer function. Multiple feature modules can be registered in one go.
Assuming you have this:
StoreModule.forRoot({ foo: fooReducer }),
StoreModule.forFeature('awesome-feat', { feat: featReducer }), // `reducer` - ActionReducerMap
StoreModule.forFeature('counter', counterReducer), // `reducer` - function
After initialization, the store will resemble:
{
'foo': /* ... */,
'awesome-feat': /* ... */,
'counter': /* ... */,
}
Let's trace how this unfolds. It begins in StoreFeatureModule, where all provided configurations get collected:
export class StoreFeatureModule /* ... */ {
constructor(
@Inject(_STORE_FEATURES) private features: StoreFeature<any, any>[],
@Inject(FEATURE_REDUCERS) private featureReducers: ActionReducerMap<any>[],
private reducerManager: ReducerManager,
root: StoreRootModule
) {
const feats = features.map((feature, index) => { /* ... */ });
reducerManager.addFeatures(feats);
}
}
Once everything (initialState, reducers, meta-reducers) is compiled into the feats array, ReducerManager takes charge. ReducerManager.addFeatures organizes the feature reducers. Keep in mind that a feature's reducer can be either a function or an object of reducers (functions).
addFeatures(features: StoreFeature<any, any>[]) {
const reducers = features.reduce(
(
reducerDict,
{ reducers, reducerFactory, metaReducers, initialState, key }
) => {
const reducer =
typeof reducers === 'function'
? createFeatureReducerFactory(metaReducers)(reducers, initialState)
: createReducerFactory(reducerFactory, metaReducers)(
reducers,
initialState
);
reducerDict[key] = reducer;
return reducerDict;
},
{} as { [key: string]: ActionReducer<any, any> }
);
this.addReducers(reducers);
}
When it's an object of reducers (like { feat: featReducer }), it adheres to the same process outlined in the "How are reducers set up?" section. Specifically, the awesome-feat reducer becomes a function taking state and action, which, when called, loops through the feature's registered reducers (here feat, created by createReducer) and invokes them with those arguments. This is the combination function:
/* ... */
return function combination(state, action) {
state = state === undefined ? initialState : state;
let hasChanged = false;
const nextState: any = {};
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i];
const reducer: any = finalReducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
return hasChanged ? nextState : state;
};
If the feature reducer is a function (from createReducer), it's directly called with state and action. The meta-reducer chain still forms, but its construction varies slightly.
This difference arises because a single function implies it's not an object of reducers, eliminating the need for a wrapper function to iterate over and invoke multiple reducers (which occurs when an object is supplied).
export function createFeatureReducerFactory<T, V extends Action = Action>(
metaReducers?: MetaReducer<T, V>[]
): (reducer: ActionReducer<T, V>, initialState?: T) => ActionReducer<T, V> {
// Pretty similar to the other approach, except that here there is no `combineReducers` function
// because the reducer is one single function
// as opposed to an object of reducers
const reducerFactory =
Array.isArray(metaReducers) && metaReducers.length > 0
? compose<ActionReducer<T, V>>(...metaReducers)
: (r: ActionReducer<T, V>) => r;
return (reducer: ActionReducer<T, V>, initialState?: T) => {
reducer = reducerFactory(reducer);
return (state: T | undefined, action: V) => {
state = state === undefined ? initialState : state;
return reducer(state, action);
};
};
}
After reducers are appropriately generated, the single source of truth (the object) gets refreshed:
addReducers(reducers: { [key: string]: ActionReducer<any, any> }) {
this.reducers = { ...this.reducers, ...reducers };
this.updateReducers(Object.keys(reducers));
}
updateReducers(featureKeys: string[]) {
this.next(this.reducerFactory(this.reducers, this.initialState));
/* ... */
}
this.next(this.reducerFactory(this.reducers, this.initialState)) ensures that on each action dispatch, every slice's reducer runs (including newly added ones). This mechanism keeps the store current whenever features are added or removed.
That's it, folks! Thanks for reading!
