Platform: RFC: feat(store) create actions with modifiers

Created on 30 Jan 2021  路  3Comments  路  Source: ngrx/platform

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?

  1. Directly call the notification service in each effect that emits the action

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:

  • This is behavior we have to unit test
  • The effects are now responsible for more than one side-effect
  • Duplicated code
  1. Directly call the notification service in a separate effect as a reaction to each "success" action

Now our effects remain single-purpose, but still

  • This is behavior we have to unit test
  • Duplicated code
  • More verbose
  1. Emit another action from each effect that emits a success, like "SuccessOccurred" which is handled by NotificationEffects in the notifications lib

Although 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.

  1. Put all three actions into the 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.

Describe any alternatives/workarounds you're currently using

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

Other information:

I have a bit of an example and implementation written in this closed PR: https://github.com/ngrx/platform/pull/2900/files

If accepted, I would be willing to submit a PR for this feature

[x] Yes (Assistance is provided if you need help submitting a pull request)
[ ] No

Store

Most helpful comment

@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!

All 3 comments

@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 createAction function (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:

  1. The developer has to be aware of the "kind" directly, which exposes an unnecessary implementation detail (and also allows them to not use a constant and instead have the possibility of messing up the kind name).
  2. There is no enforced relationship between the 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?
  3. The shape of the data for the kind can only be revealed through documentation, instead of naturally through using a function with autocomplete and lint errors.

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!

Was this page helpful?
0 / 5 - 0 ratings