DEV Community

Cover image for Track your NGRX Actions State
Federico Giacomini
Federico Giacomini

Posted on • Originally published at crocsx.hashnode.dev

Track your NGRX Actions State

In this article, I would like to propose a solution for handling the state of multiple actions inside your Ngrx store. I will assume you already know the basics of Ngrx or other Redux pattern-based state management tools and are also pretty familiar with Angular and Typescript, as I will go pretty quick on the details.

One of the most common situations when using a reactive state management library is handling asynchronous API. The most common approach to tackling async operations is creating three actions for each possible outcome (request/success/failure) and making a side effect handling each of them. It is also common to have some flags in our state that track the store's current state.

Here is a classic example in NGRX very similar to the one in the "example repository" :

actions.ts

export const userAddRequest = createAction(
  '[User] User Add Request',
  props<{username: string}>()
);

export const userAddSuccess= createAction(
  '[User] User Add Success',
  props<{username: string, id: number}>()
)

export const userAddFailure = createAction(
  '[User] User Add Failure',
  props<{message: string}>()
)
Enter fullscreen mode Exit fullscreen mode

effect.ts

  userAddRequest$ = createEffect(() =>
    this.actions$.pipe(
      ofType(userActions.userAddRequest ),
      exhaustMap(({username}) =>
        this.userService.add({username}).pipe(
          map(response => userActions.userAddSuccess(response)),
          catchError((error: any) => of(userActions.userAddFailure(error))))
      )
    )
  );

  userAddSuccess$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(userActions.userAddSuccess),
        tap(() => {
          alert('User Add Succeeded');
        })
      ),
    { dispatch: false }
  );

  userAddFailure$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(userActions.userAddFailure),
        tap(() => {
          alert('User Add Failed');
        })
      ),
    { dispatch: false }
  );
Enter fullscreen mode Exit fullscreen mode

reducer.ts

export interface State {
  users: User[];
  isLoading: boolean;
  isLoadingSuccess: boolean;
  isLoadingFailure: boolean;
}

const reducer = createReducer(
  initialState,
  on(userActions.userAddRequest, (state) => ({isLoading: true})),
  on(userActions.userAddSuccess, (state, {user}) => ({users: state.users.concat(user) , isLoading: false, isLoadingSuccess: true})),
  on(userActions.userAddFailure, (state, {user}) => ({user, isLoading: false, isLoadingFailure: true})),
);
Enter fullscreen mode Exit fullscreen mode

selector.ts

export const getLoaded = (state: State) => state.isLoadingSuccess;
export const getLoading = (state: State) => state.isLoading;
Enter fullscreen mode Exit fullscreen mode

This works nicely in many scenarios, but I found this approach to be fairly limited when we would like to give more advanced feedbacks to users.

Let's imagine the following UI :

Example UI

We have a list of users on the left and a form to create a user on the right. This page effectuates three operations on our User Store :

getUsers
deleteUser
createUser

Out of those three operations, we would like to display a specific loader on the page :

When users are being loaded, we would like to show a loader on the list.
When a user is being deleted, we would like to show
a loader ONLY on the user that is being deleted.
When a user is being created, we would like to show
a loader on the Create Button.

Example Loading UI

A single flag would not work correctly since all those operations are contained in the same store module. We would have to set a flag for each operation inside the store or add some variable in our component to hold which process is currently executed.

But this is troublesome and would add a lot of logic; what if we could track the state of each Action independently?

Tracking The State of any NGRx Action

To make things easier, we can create a unique loader store in our application that we use for the single purpose of tracking actions and their state. It allows us to track the current state of any dispatched Ngrx action that we wish to follow.

First, we replaced the booleans flags with a simple enum instead. It is quicker to change a single property and less prone to situations where a flag is forgotten in a wrong state like {isLoading: true, isSucceded: true}:

export enum ActionState {
  LOADING = 'LOADING',
  SUCCEEDED = 'SUCCEEDED',
  FAILED = 'FAILED',
}
Enter fullscreen mode Exit fullscreen mode

From here, the idea is to consider "async" actions as a single set. Each Action in a set would share a key with others, and we would use it to update the state of that operation in our loaders store.

One way to do this is to create a wrapper over our Actions using createAction with method. It will allow us to add some metadata alongside the Action definition. This metadata can be anything.

export const userAddSetkey = '[User] User Add Key';
export const userAddRequest = createAction(
  '[User] User Add Request',
  function prepare(payload: {username: string, password: string}) {
    return withLoader({ [userAddSetkey]: ActionState.LOADING }, payload);
  }
);
export const userAddSuccess = createAction(
  '[User] User Add Success',
  function prepare() {
    return withLoader({ [userAddSetkey]: ActionState.SUCCEEDED }, null);
  }
);
export const userAddFailure = createAction(
  '[User] User Add Failure',
  function prepare(payload: { message: string }) {
    return withLoader({ [userAddSetkey]: ActionState.FAILED }, payload);
  }
);
Enter fullscreen mode Exit fullscreen mode

You can assign an anonymous function; we went with prepare because it is more similar to the redux prepare. This function adds additional information to the payload and the actions when they are initialized.

As you may note, we also wrote a simple wrapper withLoader around our payload. This method will add a property key that will be the same for each Action in our application that implements the tracking. That property will be helpful to know if the dispatched Action contained a state tracker or not.

export const LOADER_KEY = '@ngrx-custom-loader';

export type WithLoader<T> = T & {
  [LOADER_KEY]: { [k: string]: ActionState };
};

export function withLoader<T>(loader: Partial<{ [k: string]: ActionState }>, payload?: T) {
  return Object.assign(payload || {}, { [LOADER_KEY]: loader }) as WithLoader<T>;
}
Enter fullscreen mode Exit fullscreen mode

When creating Action using withLoader, we will access a new property alongside type and payload that will store the action key and the state. We define this new Action structure as WithLoader<T>.

if you now log an action that implements the above structure, it will result like the following :

    {
        @ngrx-custom-loader: {'[User] User Add Key': 'LOADING'}
        type: "[User] User Add Request"
        payload: {username: 'jhon'}
    }
Enter fullscreen mode Exit fullscreen mode

Finally, we need to make some use of those loader keys. We implemented a loader store that will save the state of all actions implementing withLoader.

state.ts

export interface State {
  actionState: Record<string, ActionState>;
}

export interface LoadersPartialState {
  readonly [LOADERS_FEATURE_KEY]: State;
}

export const initialState: State = {
  actionState: {},
};
Enter fullscreen mode Exit fullscreen mode

It starts empty {} and will grow every time an action is dispatched to look something like this.

{
    '[Login] Login Key': 'SUCCEEDED',
    '[User] User Add Request': 'LOADING',
    ...
}
Enter fullscreen mode Exit fullscreen mode

Our reducer will check if the current Action contains our custom property LOADER_KEY assigned above. If yes, we will store this action state; else, it will do nothing.

reducer.ts

export function reducer(
  state: State | undefined = initialState,
  action: Action | WithLoader<Action>
) {
  if (Object.prototype.hasOwnProperty.call(action, LOADER_KEY)) {
    const loader = (action as WithLoader<Action>)[LOADER_KEY];
    return {
      ...state,
      actionState: {
        ...state.actionState,
        ...loader,
      },
    };
  }
  return state;
}
Enter fullscreen mode Exit fullscreen mode

And last, the selector will check the store content and return the state of a specific action. We can pass an array of Actions that we would like to know the state, and it will return a boolean if any of them is currently loading. You can implement the same for Failure, Success, etc., or just one that would give back the state.

selector.ts

export const getIsLoading = (actions: string[] = []) =>
  createSelector(getLoadersState, (state) => {
    if (actions.length === 1) {
      return state.actionState[actions[0]] === ActionState.LOADING;
    }
    return actions.some((action) => {
      return state.actionState[action] === ActionState.LOADING;
    });
});

// We added an additional state INIT used when the operation has never been called. 
export const getLoadingState = (action: string) =>
  createSelector(
    getLoadersState,
    (state) => state.actionState?.[action] || ActionState.INIT;
  );
Enter fullscreen mode Exit fullscreen mode

Let's use our tracking system :

We can now quickly implement our previous UI requirement :

assuming you create all Actions correctly, we can do

    // The user are getting loaded
    this.store.dispatch(loadUsersList());
    this.usersLoading$ = this.store.pipe(
      select(getIsLoading([userListLoadSetKey]))
    );

    // A user is being delete
    // for this case you also need to store what user it getting deleted to show the feedback on the correct row.
    InDeletionUserId = userId;
    this.store.dispatch(deleteUser({ id: userId }));
    this.userDeleting$ = this.store.pipe(
      select(getIsLoading([userDeleteSetKey]))
    );

    // A user is being created
    this.store.dispatch(createUser({ id: accountId }));
    this.userCreating$ = this.store.pipe(
      select(getIsLoading([userAddSetKey]))
    );

    // Any of the above is loading
    this.isUserStoreLoading$ = this.store.pipe(
      select(
        getIsLoading([userListLoadSetKey, userDeleteSetKey, userAddSetKey])
      )
    );
Enter fullscreen mode Exit fullscreen mode

By using getLoadingState, you can also track when an operation is finished; helpful in those rare cases where you would like to execute a side effect to Actions outside of an NGRx effect. For example, reset a form when a user is created :


  onSubmit() {
    this.form.controls.username.disable();
    this.store.dispatch(userAddRequest({ ...this.form.getRawValue() }));

    this.store
      .pipe(select(getLoadingState([userAddSetKey])))
      .pipe(
        takeWhile(
          (state) =>
            ![ActionState.SUCCEEDED, ActionState.FAILED].includes(state),
          true
        ),
        filter((state) => state === ActionState.SUCCEEDED),
        tap(() => this.form.controls.username.enable())
      )
      .subscribe();
  }
Enter fullscreen mode Exit fullscreen mode

You can find a demo of this approach on the following Stackblitz or Repository.

I hope I didn't go too fast and that this post was helpful. It works very fine on our project, but we might have overlooked some behaviors. One central flaw point that I didn't cover is clearing the loaders once in a while (on route navigation, for example) to avoid storing gigantic objects in your NGRx store. But this is only problematic if your project contains a lot of Action, and we currently only have 150 actions. Please feel free to comment and give feedback on such an approach. Also, do not hesitate to ask for clarification or points I passed over too quickly.

Thank you, and see you in the next post!

Discussion (0)