In my previous post, I talked about how NGRX has a lot of boiler-plate. Especially in regards to HTTP Requests. This is because you need to track the loading state, the actual request, and response objects as well as error handling. And if we’re being honest, 90% of the code is the same stuff, likely copied and pasted from other areas in the system because it’s faster to do it that way.
Well expanding on my previous post, rather than following a long, complex guide, that is out of date now as Angular and NGRX have kept evolving. It would be much simpler to just install the npm package and be done with it.
The good news is you do not need to convert all you’re actions, reducers, and effects over at once. You can easily do it, action by action, which allows you to not only get comfortable with the library but also means you can introduce it into your apps and libraries slowly.
The library itself can be found on my Github here. You can also check out a really quick demo here. We’ve been using this library at my workplace in production for 12 months now without any issues.
The library is also available on npmjs here.
Or if you want to get started straight away install it via the CLI, and look at the repository on how to us it.
npm i @acandylevey/ngrx-http-tracking
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.
Throughout this you need to track the following:
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.
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:
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:
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.
So I’ve recently been struggling with Sourcetree, the interface is slow, clunky has lots of weird bugs and quirks and chews up a huge amount of CPU at times. Integration with cloud repos doesn’t work great either. I did some research and came across GitKraken, it just works.
It’s got a dark mode as well. But beyond that, it just works, seamlessly with very little effort. Of course, it’s not free but it’s been worth every penny I spent on it.