DEV Community

Cover image for Describe Your NgRx Actions More To Write Less Code
David
David

Posted on

Describe Your NgRx Actions More To Write Less Code

Actions describe events in our NgRx-powered applications.

When we fail to sufficiently describe our actions, we get duplicative code. This results in higher maintenance cost and slower time to implement features.

Instead, we can define a structure for action metadata, and inject that into our actions when they are instantiated.

Then, we can more generically react to actions with that metadata while preserving good action hygiene and allowing actions to continue to operate for their more narrow purposes.

An example

Consider the following action:

export const LoadBillingAddressSuccess = createAction(
  '[Address API] Load Billing Address Success',
  props<{ address: Address }>()
); 
Enter fullscreen mode Exit fullscreen mode

When an instance of this action is instantiated, it will be an object that looks something like

{
  type: '[Address API] Load Billing Address Success',
  address: { /* some address */ }
}
Enter fullscreen mode Exit fullscreen mode

Examining the object, we know that the action

  • is a discrete event named [Address API] Load Billing Address Success
  • occurred for a given address

With that information, we are able to write a useful state change in our reducer:

on(LoadBillingAddressSuccess, (state, { address }) => ({
  ...state,
  billingAddress: address
}))
Enter fullscreen mode Exit fullscreen mode

The feature requirements

Say we were given two requirements for displaying this billing address on a webpage:

  • While the billing address is loading, show a loading indicator panel
  • When the billing address fails to load, show a failure toast notification

Surveying a possible implementation

For the loading indicator panel, it would make sense to have some kind of "request state" we can track.

Depending on if the request state is in progress or has completed, we can display either the loading indicator or the address component.

When we go to implement this, however, we find that this idea has already been implemented for another request state:

// in the reducer
on(LoadHomeAddressStarted, state => ({
  ...state,
  loadHomeAddressRequestState: 'started'
})),
on(LoadHomeAddressSuccessful, state => ({
  ...state,
  loadHomeAddressRequestState: 'successful'
}))

// in the selectors
export const selectLoadHomeAddressRequestState = createSelector(
  selectAddressesState,
  state => state.loadHomeAddressRequestState
);

export const selectLoadHomeAddressRequestStateIsInProgress = createSelector(
  selectLoadHomeAddressRequestState,
  requestState => requestState === 'started'
);
Enter fullscreen mode Exit fullscreen mode

Similarly, for the failure toast notification, we find that an effect already exists for the "home address" as well:

showLoadHomeAddressFailedNotification$ = createEffect(() =>
  this.actions$.pipe(
    ofType(LoadHomeAddressFailed),
    tap(() => this.notificationService.push('Failed to load Home Address', 'failure'))
  ),
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

Dealing with common requirements

While the billing address and home address-related actions are all distinct, they seem to be related by having common resulting behavior.

Without breaking good action hygiene, we can better describe our actions to easily react to them in a more generic way.

Describing actions as request state milestones

We can define a request state and describe actions as a milestone for a stage of that request state.

Without worrying about the internal details, say I have a function like createRequestState that operates like so:

export const LoadBillingAddressRequestState = createRequestState();

LoadBillingAddressRequestState.aSuccess();
// produces an object like
/*
  {
    requestStateMetadata: {
      uuid: 'some-uuid',
      milestone: 'success'
    }
  }
*/
Enter fullscreen mode Exit fullscreen mode

Then by using the "creator" API of createAction, we can inject this metadata into the payload of our action:

export const LoadBillingAddressSuccess = createAction(
  '[Address API] Load Billing Address Success',
  (properties: { address: Address }) => ({
    ...properties,
    ...LoadBillingAddressRequestState.aSuccess()
  })
); 
Enter fullscreen mode Exit fullscreen mode

The action is still instantiated the same way, but now produces an object like this:

LoadBillingAddressSuccess({ address: someBillingAddress })

/* produces
  { 
    type: '[Address API] Load Billing Address Success',
    address: someBillingAddress,
    requestStateMetadata: {
      uuid: 'some-uuid',
      milestone: 'success'
    }
  }
*/
Enter fullscreen mode Exit fullscreen mode

Now that we have requestStateMetadata on the action, we can react to it in a more generic way:

// in new request-state.effects.ts
mapToRequestStateChanged$ = createEffect(() =>
  this.actions$.pipe(
    filter(action => !!action['requestStateMetadata']),
    map(action => RequestStateChanged(action['requestStateMetadata']))
  )
);

// in new request-state.reducer.ts
on(RequestStateChanged, (state, { uuid, milestone }) => ({
  ...state,
  [uuid]: milestone
)}) 
Enter fullscreen mode Exit fullscreen mode

Our existing reducer code to update the billing address in the address reducer still works just fine! But now we're also progressing the state for this request in a way that's easy to read straight from the action declaration.

As a bonus, we could implement a selector within the object our magic createRequestState function produces such that we can easily select if the request state is in progress:

LoadBillingAddressRequestState.selectIsInProgress();

/* produces a selector like
  createSelector(
    selectRequestState(uuid),
    requestState => requestState === 'started'
  );
*/
Enter fullscreen mode Exit fullscreen mode

Describing actions as notifiable failures

Implementing a similar metadata approach for notifications is simple.

We can declare a function that operates like so:

aNotifiableFailure('A failure message.');
// produces an object like
/*
  {
    failureNotificationMetadata: {
      message: 'A failure message.'
    }
  }
*/
Enter fullscreen mode Exit fullscreen mode

Again, we can describe our action with the aNotifiableFailure metadata creator.
Interestingly, if we want our failure message to be dynamic based on a property from the action, we can do that!

export const LoadBillingAddressFailure = createAction(
  '[Address API] Load Billing Address Failure',
  (properties: { serverError: ServerError }) => ({
    ...properties,
    ...aNotifiableFailure(serverError.message)
  })
); 
Enter fullscreen mode Exit fullscreen mode

The action creator will work like so:

LoadBillingAddressFailure({ serverError: someServerError })

/* produces
  { 
    type: '[Address API] Load Billing Address Failure',
    serverError: someServerError,
    failureNotificationMetadata: {
      message: someServerError.message
    }
  }
*/
Enter fullscreen mode Exit fullscreen mode

And now all failures can be handled in one effect:

// in new notifications.effects.ts
showFailureNotification$ = createEffect(() =>
  this.actions$.pipe(
    filter(action => !!action['failureNotificationMetadata']),
    tap(() => this.notificationService.push(action['failureNotificationMetadata'].message, 'failure'))
  ),
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

Handling descriptive actions reduces code

By injecting metadata into our actions, we reduce the amount of code we have to write for handling similar behavior across our application while maintaining good action hygiene.

The pattern also increases the usefulness of the actions file by giving a reader a more complete picture of what an action represents.

Top comments (1)

Collapse
 
martinsotirov profile image
Martin Sotirov

It's an interesting idea but I think it's better to have actions that are extremely granular. Why would a "FetchBlogPosts" action for example know anything about loading state or error messages? Those are a responsibility of the consuming service or component that dispatches the "FetchBlogPosts" action.

In my apps when I use a store solution (be it Vuex, ngxs or NgRx) I usually have a global store containing a loading flag and error messages, and then the individual feature stores know nothing about those. In my mind it is the consuming component's responsibility to set the loading flag (or better yet just intercept http requests in your router).