David
Posted on March 6, 2021
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 }>()
);
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 */ }
}
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
}))
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'
);
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 }
);
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'
}
}
*/
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()
})
);
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'
}
}
*/
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
)})
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'
);
*/
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.'
}
}
*/
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)
})
);
The action creator will work like so:
LoadBillingAddressFailure({ serverError: someServerError })
/* produces
{
type: '[Address API] Load Billing Address Failure',
serverError: someServerError,
failureNotificationMetadata: {
message: someServerError.message
}
}
*/
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 }
);
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.
Posted on March 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.