Angular Global Http Tracking Error Handling with NGRX

NGRX is great, but it has a ton of boilerplate involvement, especially once you start making HTTP requests. You usually have to create actions, reducers, effects and selectors. Now moving to the creator pattern for NGRX will already save you a ton of boilerplate, but there is still too much boilerplate code being written.

Especially when you consider HTTP requests, you’ll usually end up with 3 actions, reducer maps and a few selectors to display the state of the request.

  • Fetch data
  • Fetch data success
  • Fetch data failure

Throughout this you need to track the following:

  • Is the data loaded?
  • Was the data loaded successfully?
  • Did it return an error message?

Then you need to do a bit of work to show loading masks and spinners, to show error messages when they appear and then once you’ve done all that - You can start actually using the data.

It took me a few minutes to write out the process, writing the code, whilst not hard is tedious, error-prone and eventually, something will go wrong.

Enter global HTTP tracking and error handling.

Tracking HTTP requests and their states globally

By extending on the creator pattern and making use of some smart defaults, I’ve been able to make significant reductions in the amount of code needed to write.

Now when setting up a Fetch request I have to write the following:

  • The fetch action with both send and return typings
  • Effect to do the actual API request
  • Reducer than only needs to listen to the success action

It’s important to note, we don’t always want to have everything auto handled, you might, for instance, want to show the loading spinner yourself still or to handle any error messages outside of the normal.

But for 95% of my actions having a site-wide loading mask and spinner works. If an error occurs having it show up in a dialog box also works for me about 90% of the time - Of course if it’s related to form validation, then you can always handle that manually outside of the HTTP Tracking module.

This module has been built with an nx mono-repo in mind. Click here to go to the Github Repo.

So first up we need some interfaces to model our loading states.

export enum LoadingState {
  INIT = 'INIT',
  LOADING = 'LOADING',
  LOADED = 'LOADED',
}

export interface HttpTrackingEntity {
  httpStatus: LoadingState | Error;
  action: string;
  globallyManaged: boolean;
}

Now we’ll also need a way to automatically map our actions to the action property.

export function mapActionTypeToId(actionType: string) {
  return actionType
    .toLowerCase()
    .replace(/success/g, '')
    .replace(/failure/g, '')
    .trim()
    .replace(/ /g, '-');
}

You’ll notice that we also remove success and failure options, this is so that when we define our actions as:

  • FetchUser
  • FetchUserSuccess
  • FetchUserFailure

That we know that these are all part of the same request.

Okay, let’s create the action that will handle the loading state for us.

const ACTIONS_NAMESPACE = 'Http Tracking';
export const TrackHttpRequest = createAction(
  `[${ACTIONS_NAMESPACE}] TrackHttpRequest`,
  props<HttpTrackingEntity>()
);

We also have a reducer that that listens for this action and update the store.

const httpTrackingReducerFunction = createReducer(
  initialState,
  on(
    HttpTrackingActions.TrackHttpRequest,
    (state, { action, httpStatus, globallyManaged }) =>
      httpTrackingAdapter.upsertOne(
        { action, httpStatus, globallyManaged },
        state
      )
  )
);

Okay, so now we need to hook into all of the actions that are being used throughout the application, good thing is, effects listen to all actions so we can d our filtering within the effect.

trackHttpRequest$ = createEffect(
  () =>
    this.actions$.pipe(
      filter(
        (action) =>
          action['httpStatus'] !== undefined &&
          action.type !== TrackHttpRequest.type
      ),
      tap((action) => {
        this.store.dispatch(
          TrackHttpRequest({
            httpStatus: action['httpStatus'],
            action: mapActionTypeToId(action.type),
            globallyManaged: action['globallyManaged'],
          })
        );
      })
    ),
  {
    dispatch: false,
  }
);

So now whenever we fire off actions that we have marked as using our HttpStatus, we will automatically update the store with the TrackHttpRequest action.

There are also a number of selectors that we will need access to so that we can listen globally for our loading and error sates. Here’s the facade that we’re using to get that state information. It’s important to note that we’ve added ways to hook into the store for individual actions in case you want to use the HTTP Tracking Module, but you want to handle how to show the loading/error states on your own.

    export class HttpTrackingFacade {
        public getTracking(action: Action): Observable<HttpTrackingEntity> {
            return this.store.select(HttpTrackingSelectors.selectOneHttpTracking, mapActionTypeToId(action.type));
        }

        public isLoading<T>(action: Action): Observable<boolean> {
            return this.getTracking(action).pipe(map(x => x?.httpStatus === LoadingState.LOADING));
        }

        public isLoaded<T>(action: Action): Observable<boolean> {
            return this.getTracking(action).pipe(map(x => x?.httpStatus === LoadingState.LOADED));
        }

        public isInit<T>(action: Action): Observable<boolean> {
            return this.getTracking(action).pipe(map(x => !isDefined(x) || x.httpStatus === LoadingState.INIT));
        }

        public getError<T>(action: Action): Observable<string | null> {
            return this.getTracking(action).pipe(
                map(x => this._getError(x?.httpStatus)),
                filter(isDefined)
            );
        }

        public clearGloballyHandledErrors() {
            this.store.dispatch(HttpTrackingActions.ClearGloballyHandledErrors());
        }

        public clearTrackingMulti(actions: Action[]) {
            actions.forEach(action => {
                this.store.dispatch(
                    HttpTrackingActions.TrackHttpRequest({
                        action: mapActionTypeToId(action.type),
                        httpStatus: LoadingState.INIT,
                        globallyManaged: action['globallyManaged'],
                    })
                );
            });
        }

        public getGlobalLoading() {
            return this.store.select(HttpTrackingSelectors.selectGlobalLoading).pipe(debounceTime(10));
        }

        public getGlobalErrors() {
            return this.store.select(HttpTrackingSelectors.selectGlobalErrors).pipe(debounceTime(10));
        }

        constructor(private store: Store<fromHttpTracking.HttpTrackingPartialState>) {}

        private _getError(status: LoadingState | Error): string | null {
            return isDefined((status as Error)?.message) ? (status as Error).message : undefined;
        }

But by listening against the getGlobalLoading() we can tie that to a site-wide loading mask for example that will show.

Lastly, we have an HTTP Tracking Actions Factory, that extends the creator pattern to make creating these HTTP Tracked actions super simple.

import { ActionCreator, Creator } from '@ngrx/store';
import {
  FunctionWithParametersType,
  NotAllowedCheck,
  Props,
  TypedAction,
} from '@ngrx/store/src/models';
import { convertResponseToError } from './convert-response-to-error.function';
import { LoadingState } from './http-tracking.models';

function createTrackingAction<T extends string>(
  type: T,
  globallyManaged: boolean,
  httpStatus: LoadingState
): ActionCreator<T, () => TypedAction<T>>;

function createTrackingAction<T extends string, P extends object>(
  type: T,
  globallyManaged: boolean,
  httpStatus: LoadingState,
  config: Props<P> & NotAllowedCheck<P>
): ActionCreator<T, (props: P & NotAllowedCheck<P>) => P & TypedAction<T>>;

function createTrackingAction<
  T extends string,
  P extends any[],
  R extends object
>(
  type: T,
  globallyManaged: boolean,
  httpStatus: LoadingState,
  creator: Creator<P, R> & NotAllowedCheck<R>
): FunctionWithParametersType<P, R & TypedAction<T>> & TypedAction<T>;

function createTrackingAction<T extends string, C extends Creator>(
  type: T,
  globallyManaged: boolean,
  httpStatus: LoadingState,
  config?: { _as: 'props' } | C
): ActionCreator<T> {
  if (typeof config === 'function') {
    return defineType(type, (...args: any[]) => ({
      ...config(...args),
      type,
      httpStatus,
      globallyManaged,
    }));
  }
  const as = config ? config._as : 'empty';
  switch (as) {
    case 'empty':
      return defineType(type, () => ({
        type,
        httpStatus,
        globallyManaged,
      }));
    case 'props':
      return defineType(type, (props: object) => ({
        ...props,
        type,
        httpStatus,
        globallyManaged,
      }));
    default:
      throw new Error('Unexpected config.');
  }
}

function createTrackingFailureAction<
  T extends string,
  err extends any,
  fallbackMsg extends string
>(
  type: T,
  globallyManaged: boolean,
  httpStatus: (err: any, fallbackMsg: string) => Error
): ActionCreator<T, (err: any, fallbackMsg: string) => TypedAction<T>>;

function createTrackingFailureAction<
  T extends string,
  err extends any,
  fallbackMsg extends string,
  P extends object
>(
  type: T,
  globallyManaged: boolean,
  httpStatus: (err: any, fallbackMsg: string) => Error,
  config: Props<P> & NotAllowedCheck<P>
): ActionCreator<
  T,
  (
    err: any,
    fallbackMsg: string,
    props: P & NotAllowedCheck<P>
  ) => P & TypedAction<T>
>;

function createTrackingFailureAction<T extends string, C extends Creator>(
  type: T,
  globallyManaged: boolean,
  httpStatus: (err: any, fallbackMsg: string) => Error,
  config?: { _as: 'props' } | C
): ActionCreator<T> {
  if (typeof config === 'function') {
    return defineType(
      type,
      (err: any, fallbackMsg: string, ...args: any[]) => ({
        httpStatus: httpStatus(err, fallbackMsg),
        ...config(...args),
        type,
        globallyManaged,
      })
    );
  }
  const as = config ? config._as : 'empty';
  switch (as) {
    case 'empty':
      return defineType(type, (err: any, fallbackMsg: string) => ({
        httpStatus: httpStatus(err, fallbackMsg),
        type,
        globallyManaged,
      }));
    case 'props':
      return defineType(
        type,
        (err: any, fallbackMsg: string, props: object) => ({
          httpStatus: httpStatus(err, fallbackMsg),
          ...props,
          type,
          globallyManaged,
        })
      );
    default:
      throw new Error('Unexpected config.');
  }
}

function defineType<T extends string>(
  type: T,
  creator: Creator
): ActionCreator<T> {
  return Object.defineProperty(creator, 'type', {
    value: type,
    writable: false,
  });
}

export const createTrackingActions = <T1 extends object, T2 extends object>(
  namespace: string,
  actionName: string,
  loadingProps: Props<T1> & NotAllowedCheck<T1>,
  loadedProps: Props<T2> & NotAllowedCheck<T2>,
  globallyManaged = true
) => ({
  loading: createTrackingAction(
    `[${namespace}] ${actionName}`,
    globallyManaged,
    LoadingState.LOADING,
    loadingProps
  ),
  loaded: createTrackingAction(
    `[${namespace}] ${actionName}Success`,
    globallyManaged,
    LoadingState.LOADED,
    loadedProps
  ),
  failure: createTrackingFailureAction(
    `[${namespace}] ${actionName}Failure`,
    globallyManaged,
    convertResponseToError
  ),
});

Our endpoints always return error messages to the front-end in the following format so we want to ensure that we can read this format.

    {
    	error: "<message>",
        stackTrace?: "<stacktrace>"
    }

So we have a conversion function that helps convert that error object into something we can use in our store.

export const convertResponseToError = (err: any, fallbackMessage: string) => {
  if (err && err.name === 'HttpErrorResponse' && err.error && err.error.error) {
    return new Error(err.error.error);
  } else {
    return new Error(fallbackMessage);
  }
};

Bringing it all together

Ok now that we’ve gotten our module setup, how do we use it?

Let’s fetch a list of paginated users, first, we need to create our fetch actions, including success & failure, by using the action factory this becomes a pretty simple, one function call.

export const fetchUsers = createTrackingActions(
  'user-admin',
  'fetchUsers',
  props<UserQuery>(),
  props<{ response: PaginationResponse<User> }>()
);

Okay, let’s make sure our reducer is doing what it needs to do.

const userAdminReducer = createReducer(
  initialState,
  on(UserAdminActions.fetchUsers.loaded, (state, { response }) => ({
    ...state,
    users: userAdapter.setAll(response.data, {
      ...state.users,
      filteredCount: response.filteredCount,
    }),
  }))
);

In this case, you’ll note that we don’t have to listen for the loading or the failure actions as the HTTP Tracking Module takes care of that. You may have to listen to the loading action if you do need to clear our the store during a loading event, however.

And our effect becomes.

loadUsers$ = createEffect(() =>
  this.actions$.pipe(
    ofType(UserAdminActions.fetchUsers.loading),
    fetch({
      run: (action) => {
        return this.userAdminService
          .fetchUsers(action)
          .pipe(
            map((data) =>
              UserAdminActions.fetchUsers.loaded({ response: data })
            )
          );
      },

      onError: (action, error) => {
        console.error('Error', error);
        return UserAdminActions.fetchUsers.failure(
          error,
          'Could not fetch users'
        );
      },
    })
  )
);

Okay, and that’s it. If your listening for globally handled actions in your app component, you don’t need to worry about load state or error state.

Related Articles