In the counter example, action and reducer is one to one mapping. It does not show the flexibility of decoupling action and reducer. I need to use a more complex example to showcase that. In the following, I will build sales app with store. Here is the features of the app. After login, the app will load your recent orders and you preferences, and you can logout from there. After login, the session will timeout and logout if screen is idle for 10 seconds. If you move your mouse, session will be renewed. After session timeout or user logout manually, the session tracking will be turn off.

All the source code of the post can be found here. The live demo is here

First let’s see how the component consume the store features.

export class SalesStoreComponent {
  constructor(private store: Store) {}

  sales$ = this.store.select(salesSelectors.selectSalesState);

  remainingTime$ = interval(1000).pipe(
    concatLatestFrom(() =>
      this.store.select(salesSelectors.selectSessionValidSince)
    ),
    map(([, sessionValidSince]) =>
      Math.ceil(
        (sessionValidSince!.getTime() +
          expiredInSeconds * 1000 -
          new Date().getTime()) /
          1000
      )
    )
  );

  login() {
    this.store.dispatch(
      homePageEvents.logIn({ userName: "johndoe", password: "1234" })
    );
  }

  logout() {
    this.store.dispatch(homePageEvents.logOut());
  }
}

Because I use action as event, so I use these two term interchangeablely in the following. To login, we raise an event homePageEvents.login, to logout, we raise an event homePageEvents.logout. The component has sales$ observable for the sales data, and remainingTime$ to show how much time is left for the session.

I use createActionGroup instead of createAction to create actions as below, because it you organize actions better with consistent naming convention.

export const homePageEvents = createActionGroup({
  source: "Sales Home Page",
  events: {
    "log in": props<{ userName: string; password: string }>(),
    "log out": emptyProps(),
  },
});

export const apiEvents = createActionGroup({
  source: "Api",
  events: {
    "login Success": props<UserInfo>(),
    "order loaded": props<{ orders: string[] }>(),
    "preferences loaded": props<{ preferences: string[] }>(),
  },
});

export const systemEvents = createActionGroup({
  source: "System",
  events: {
    "session timeout": emptyProps(),
    "session renewed": emptyProps(),
  },
});

There are three groups of events used here, one is raised from the home page, and one is raised in effect, when api returns data, and one raised from system background process. Here I follow the naming convention [event source] what happened.

The effects code is as follow.

@Injectable({
  providedIn: "root",
})
export class ApiEffects {
  constructor(private actions$: Actions, private apiService: ApiService) {}

  login$ = createEffect(() =>
    this.actions$.pipe(
      ofType(homePageEvents.logIn),
      concatMap(({ userName, password }) =>
        this.apiService
          .login(userName, password)
          .pipe(map((value) => apiEvents.loginSuccess(value)))
      )
    )
  );

  getUserOrders$ = createEffect(() =>
    this.actions$.pipe(
      ofType(apiEvents.loginSuccess),
      concatMap(({ userName }) =>
        this.apiService
          .getUserOrders(userName)
          .pipe(map((orders) => apiEvents.orderLoaded({ orders })))
      )
    )
  );

  getPreferences$ = createEffect(() =>
    this.actions$.pipe(
      ofType(apiEvents.loginSuccess),
      concatMap(({ userName }) =>
        this.apiService
          .getPreferences(userName)
          .pipe(
            map((preferences) => apiEvents.preferencesLoaded({ preferences }))
          )
      )
    )
  );
}

Injectable({
  providedIn: "root",
});
export class SessionEffects {
  constructor(private actions$: Actions, private store: Store) {}

  trackSession$ = createEffect(() =>
    this.actions$.pipe(
      //the effect handle to events
      ofType(apiEvents.loginSuccess, homePageEvents.logOut),
      // switchMap can cancel if user logout manually
      switchMap((event) => {
        return event.type === apiEvents.loginSuccess.type
          ? fromEvent(document, "mousemove").pipe(
              debounceTime(300),
              tap(() => {
                // normally an effect should only return one action
                // this is hack
                this.store.dispatch(systemEvents.sessionRenewed());
              }),
              // start tracking at the first time without waiting mouse move
              startWith(true),
              // switchMap can cancel the previous setup up by previous mouse event
              switchMap(() => timer(expiredInSeconds * 1000))
            )
          : // in case user log out, stop tracking
            EMPTY;
      }),
      map(() => systemEvents.sessionTimeout())
    )
  );
}

The Api effects map to the 3 api methods. The first one login$ handles an event homePageEvents.login, after apiService.login returns data, it will raise an event apiEvents.loginSuccess. The other two effects getUerOrders and getPreferences will respond this event, and load additional data of the authenticated user.

The System effect tracks handle two events. If it is apiEvents.loginSuccess, the effect will track user’s mouse move to renew session and restart a timer to log user out in 10 seconds. If event is homePageEvents.logOut, it will cancel mouse tracking.

In the following, I use createFeature function to create both reducers and selector at the same time.

const salesFeature = createFeature({
  name: "sales",
  reducer: createReducer(
    initialState,
    //one action trigger two reducers
    on(apiEvents.loginSuccess, function updateUser(state, user) {
      return { ...state, user };
    }),
    //one reducer respond to two actions
    on(
      apiEvents.loginSuccess,
      systemEvents.sessionRenewed,
      function renewSession(state) {
        return { ...state, sessionValidSince: new Date() };
      }
    ),
    //
    on(apiEvents.orderLoaded, function setOrders(state, { orders }) {
      return { ...state, orders };
    }),
    on(
      apiEvents.preferencesLoaded,
      function setPreferences(state, { preferences }) {
        return { ...state, preferences };
      }
    ),
    //one reducer respond to two actions
    on(
      homePageEvents.logOut,
      systemEvents.sessionTimeout,
      function clearSalesData() {
        return {} as SalesState;
      }
    )
  ),
});

export const salesSelectors = salesFeature as Omit<
  typeof salesFeature,
  "name" | "reducer"
>;

export const salesStoreModules = [
  StoreModule.forFeature(salesFeature),
  EffectsModule.forFeature(ApiEffects, SessionEffects),
];

One action , multiple reducers scenario

You can see that the reducers of sales state respond to actions despatched from multiple source, the home page, api and system, because I use action as event instead of command. The apiEvents.loginSuccess event can trigger two reducers (updateUser and updateSessionValidSince) This is powerful in that the consumer just need raise a single event, and multiple reducers can react to it, and each of them update the state in their domain. If we use two events for two reducers, consumer will have to remember to raise two events to trigger two reducers. Here the consumer is free from the update logics of different state, and the consumer code is more decorative instead of imperative.

One reducer, multiple actions scenario.

The renewSession reducer can handle two events, apiEvents.loginSuccess and systemEvents.sessionRenewed. And the clearSalesData reducer can handle homePageEvents.logOut and systemEvents.sessionTimeout.

The benefit is that your reducer is more atomic. If a reducer update one and only one piece of state, so it is more reusable, and it can handle more events.

Another benefit is that, we can trace where this reducer is trigger by looking at the source of the event. If you need raise the same event in different places, it is hard to pinpoint know where this event is raised exactly. But If we use different events in different places, even they trigger the same reducer, we can easily trace where reducer is triggered.

The createFeature function also automatically create selectors like the following.

Imgur

However, I don’t really need to expose the name and reducer member, so I export the salesSelector instead of salesFeature, as a type without these two member, like the following. The type util Omit is used to remove the two members. For other type utils, see here

Imgur

In this example, there are quite a few of reducers, effects, and events. For complex state management, it can scale better, because you control every details what is changed in what scenario, and this details are traceable. But you definitely need to write more code. You also need adopt the habit of using action as event rather command.

Building Sales app with component store.

Can we implement the Sales app thing with component store. Absolutely. In fact you can write much less code. All you need is a single ComponentStore service. Here is the consumer code.

export class SalesCmpStoreComponent {
  constructor(private salesService: SalesCmpStore) {}

  sales$ = this.salesService.state$;

  remainingTime$ = interval(300).pipe(
    concatLatestFrom(() =>
      this.salesService.select((state) => state.sessionValidSince)
    ),
    map(([, sessionValidSince]) =>
      Math.ceil(
        (sessionValidSince!.getTime() +
          expiredInSeconds * 1000 -
          new Date().getTime()) /
          1000
      )
    )
  );

  login() {
    this.salesService.login({
      userName: "johndoe",
      password: "1234",
    });
  }

  logout() {
    this.salesService.logout();
  }
}

Here the component only need to talk to one single object salesService, which is ComponentStore. No more action(event), no more selector. The service give you methods to update state, the observable to display in UI. That was easy. The following is the code of the ComponentStore

export class SalesCmpStore extends ComponentStore<SalesState> {
  constructor(private salesApi: ApiService) {
    super({} as SalesState);
  }

  login = this.effect(
    (userInfo$: Observable<{ userName: string; password: string }>) => {
      return userInfo$.pipe(
        concatMap(({ userName, password }) => {
          return this.salesApi.login(userName, password).pipe(
            tap((userInfo) => {
              this.setUser(userInfo);
              this.renewSession();
              this.trackSession();
            })
          );
        }),
        concatMap(({ userName }) => {
          return merge(
            this.salesApi.getPreferences(userName).pipe(
              tap((preferences) => {
                this.setPreferences(preferences);
              })
            ),
            this.salesApi.getUserOrders(userName).pipe(
              tap((orders) => {
                this.setOrders(orders);
              })
            )
          );
        })
      );
    }
  );

  trackSession = this.effect(() => {
    return this.select((s) => s.user).pipe(
      switchMap((user) => {
        return !!user
          ? fromEvent(document, "mousemove").pipe(
              debounceTime(300),
              tap(() => this.renewSession()),
              startWith(true),
              switchMap(() => timer(expiredInSeconds * 1000))
            )
          : EMPTY;
      }),
      tap(() => this.logout())
    );
  });

  logout = this.updater(() => ({} as SalesState));

  setUser = this.updater((state, userInfo: UserInfo) => {
    return { ...state, user: userInfo };
  });

  setPreferences = this.updater((state, preferences: string[]) => {
    return { ...state, preferences };
  });

  setOrders = this.updater((state, orders: string[]) => {
    return { ...state, orders };
  });

  renewSession = this.updater((state) => {
    return { ...state, sessionValidSince: new Date() };
  });
}

To implement the state, all you need just one ComponentStore. The most complex part of code of the login effect. It has lots of work to do.

  1. call the login api,
  2. set user info to state
  3. renew session
  4. preference api
  5. set preferences to state,
  6. call get orders api
  7. set orders to state

The code is pretty imperative. It feels that we squeeze lots of code into the one class.

Building sales app in BehaviorSubject ?

Can we build sales in BehaviorSubject or OtherSubject in RxJs? Absolutely. But wait, before I do that, I check the source code of the ComponentStore. It turns out, internally it use RxJs subjects. And the whole source code is just one file and it does not depends on any thing else in the library. Including lots of spacial formatting, and and comments, it is just about 532 lines code. So there is no reason to implement it here. Just use ComponentStore if you want to implement it with BehaviorSubject.

BehaviorSubject, ComponentStore or Store, Which to choose.

If you state is simple, BehaviorSubject is all you need. Otherwise, just use ComponentStore.

As for Component store vs Store, NgRx provide a Comparison of ComponentStore and Store. The guide is very comprehensive. The following is my personal summary.

Here are some of benefit of Store

  1. You can have full control of the relationship between reducer/effect and actions, you have the flexibility of their combination.
  2. You can trace source of the change better.
  3. You can track state change of with dev tools
  4. The code can be more declarative.

Here are some downside of store

  1. You write a little bit more code.
  2. It is global, everybody can raise an action to change it.

Here are some of the benefit ComponentStore

  1. The state can be global, or local, depending how you provide it.
  2. If it is local state, it has is life span is the same as your component.
  3. The code is less than ComponentStore

Here are some downside of the ComponentStore

  1. The code can be more imperative.
  2. Does not have the flexibility mix and match reducers and actions.
  3. You can’t track state change of with dev tools.