The Problem
Events in an application can be organized into similar categories. But there is not a good way to take advantage of those categories today.
Let's take a simple blogging application as an example with the following libraries:
blog-editor
blog-reader
notifications
Then say we have the following actions defined somewhere in each lib:
... SaveUserProfileSuccess
blog-editor
... SaveBlogDraftSuccess
blog-reader
... BookmarkedSuccess
notifications
For all three actions, we should show a toast notification with a message specific to the action.
Then we should probably have a notificationService in the notifications library with a method showNotification(message).
But how will we trigger the notification?
Assuming that each action is dispatched from some unique effect like saveUserProfile$, saveBlogDraft$, etc., then we could use tap to directly invoke the notificationService in each effect.
The drawback of this approach is that:
Now our effects remain single-purpose, but still
NotificationEffects in the notifications libAlthough we can avoid directly invoking the notification service, we break good action hygiene by creating an action that will be reused in many places with no singular source.
ofType of some singular showNotification$ effect.The notification lib cannot import from the other libs. If we developed some library that imported from all other libs to show notifications, we could do this, but as the number of actions increased this effect would become unreasonably large. Although we should avoid sub-typing actions, we also cannot always directly import actions from many libs into a singular "god" lib.
What I'm attempting to illustrate in this simple notification example is that NgRx does not provide any way today to handle events which should have similar outcomes.
The actions in the example above may very well also cause updates in some reducer to occur, or trigger other side effects, but in addition, we want to have some way to repeatedly add metadata to our actions so that a NotificationEffects class can recognize "success" actions and show a success toast notification.
At my workplace we've been taking the approach of having modifiers which attach metadata (which could be static or dynamic based on the action payload). This allows us to declare actions that represent a single event and _what kind_ of event it is.
Using SaveUserProfileSuccess as an example, we declare something like this:
// in the notifications library, we export a modifier
export const ASuccess = createCategory('success-notification-message');
export const isASuccess = createActionModifier(
(config: { message: string }) => ({
[ASuccess.key]: config.message,
})
);
// in the user profile library, we declare our action
const SaveUserProfileSuccess = createActionWithModifiers(
'[User Profile Effects] Save User Profile Success',
props<{ profile: UserProfile }>(),
() => hasNotification({ message: 'User profile updated!' })
);
Then when we instantiate the action, it has the following shape:
{
type: '[User Profile Effects] Save User Profile Success',
profile: { /*...some profile*/ },
['notification-message']: 'User profile updated!'
}
Now our action can still be consumed as it would be normally, and we can additionally have some effect in the notifications lib like showNotification$ which does the following:
showNotification$ = createEffect(() =>
this.actions$.pipe(
ofKind(ASuccess),
tap(action => this.notificationService.showNotification(action[ASuccess.key]))
)
{ dispatch: false }
);
Now we are able to declare shareable properties of actions that have desired outcomes in our system.
We also continue to follow good action hygiene:
_Upfront - write actions before developing features to understand and gain a shared knowledge of the feature being implemented._
Now we can prescribe more behavior ahead of time just by opening an actions file and describing actions more thoroughly.
_Divide - categorize actions based on the event source._
Actions still describe their source as normal
_Many - actions are inexpensive to write, so the more actions you write, the better you express flows in your application._
We continue to write actions which express flow in our application
_Event-Driven - capture events not commands as you are separating the description of an event and the handling of that event._
In the provided example, we are describing that actions represent a "success", not that they should show a notification.
_Descriptive - provide context that are targeted to a unique event with more detailed information you can use to aid in debugging with the developer tools.+
Actions are now more descriptive
I have a bit of an example and implementation written in this closed PR: https://github.com/ngrx/platform/pull/2900/files
[x] Yes (Assistance is provided if you need help submitting a pull request)
[ ] No
@david-shortman
Interesting idea 馃檪
However, I think that you can achieve the same goal using existing createAction function (but with less code and less complexity) 馃檭
const saveUserSuccess = createAction('[User Effects] Save User Success', (user: User) => ({
user,
kind: 'SAVE_SUCCESS',
message: `User: ${user.name} is saved successfully.`,
}));
type NotificationAction = Action & { kind: string; message: string; };
function ofKind(kind: string): MonoTypeOperatorFunction<NotificationAction> {
return filter(action => action.kind === kind);
}
showNotification$ = createEffect(() =>
this.actions$.pipe(
ofKind('SAVE_SUCCESS'),
tap(({ message }) => this.notificationService.showNotification(message)),
),
{ dispatch: false }
);
@david-shortman
However, I think that you can achieve the same goal using existing
createActionfunction (but with less code and less complexity) 馃檭
Thanks for the code example! Although I agree that initially the code appears simpler, I'm concerned about a few things:
kind name).kind and the data that should be provided for actions of that kind. What will make sure I provide a message of type string for 'SAVE_SUCCESS' actions?Now, the example could be modified a bit to address some of the type safety concerns:
// exported somewhere else
export const SAVE_SUCCESS = 'SAVE_SUCCESS';
export type SuccessAction<T> = ActionCreator<T, (user: User) => { user: User, kind: SAVE_SUCCESS, message: string }>;
// we could type the action
const saveUserSuccess: SuccessAction<'[User Effects] Save User Success'> = createAction('[User Effects] Save User Success', (user: User) => ({
user,
kind: 'SAVE_SUCCESS',
message: `User: ${user.name} is saved successfully.`,
}));
But this starts to look clunky, and really there's not a good way build the kinds of desired types I'd want without using a function in Typescript.
I'm definitely not saying I've landed on the right API for this thing, but I do think wrapping this idea in a type-safe API leads to better outcomes than developers writing it raw.
@markostanimirovic I had a revelation and realized you were totally right.
Now I'm simply declaring metadata like this:
const SaveUserProfileSuccess = createAction(
'[User Profile Effects] Save User Profile Success',
(payload: { profile: UserProfile }) => ({
...payload,
...hasNotification({ message: 'User profile updated!' })
})
);
Simple "metadata" builders like hasNotification are just functions that return data that can be put into the payload of an action for which effects/reducers react.
Thanks for the suggestion!
Most helpful comment
@markostanimirovic I had a revelation and realized you were totally right.
Now I'm simply declaring metadata like this:
Simple "metadata" builders like
hasNotificationare just functions that return data that can be put into the payload of an action for which effects/reducers react.Thanks for the suggestion!