David
Posted on October 21, 2020
These days people say to me, "David, you look tired. Have you had trouble sleeping?"
The answer is yes, and here's why: There's a lot of scary code out there using NgRx Effects in ways that make it really hard to understand.
But I want to give you assurance that you can play a role in helping me sleep better tonight by making three tiny changes to your NgRx code.
First...
#1: Name effects like functions
An example will make this clearer.
Here's an effect named by what triggers it:
formValidationSucceeded$ = createEffect(() =>
// the effect body...
);
Without analyzing the body of the effect, I have no idea what side-effect this effect causes.
If we look at the internals of the effect, we discover it does something specific:
formValidationSucceeded$ = createEffect(() =>
this.actions$.pipe(
ofType(FormValidationSucceeded, ForceSaveClicked),
withLatestFrom(this.store.select(formSelector)),
switchMap(([action, form]) =>
// Oh! It saves the form.
this.saveForm(form).pipe(
map(() => SaveFormSucceeded()),
catchError(() => of(SaveFormFailed()))
)
)
)
);
Because the effect saves the form, then it should be named saveForm$
.
Naming an effect by its result really helps when an effect is triggered by multiple actions, as in the example above.
Because the effect also fires when ForceSaveClicked
, then this new effect name makes it clear that, when either action occurs, the form will be saved.
When you name effects like functions, bad effects also become more evident. Effects that do more than one thing result in names that include words like "And" or "Or".
Which leads me to...
#2: Make your effect only do one thing
Imagine if there was an effect that reacted to a user clicking a "Save" button for a form like so:
saveForm$ = createEffect(() =>
this.actions$.pipe(
ofType(SaveButtonClicked, ForceSaveClicked),
withLatestFrom(this.store.select(FormSelector)),
switchMap(([action, form]) => {
if (action.type === ForceSaveClicked.type
|| this.validationService.isValid(form)) {
return this.saveForm(form).pipe(
map(() => SaveFormSucceeded()),
catchError(() => of(SaveFormFailed()))
);
}
return of(FormValidationFailed());
})
)
);
This effect does not in fact "saveForm" every time it runs. It may not save the form if the validation fails. And even more horribly, it checks the action type because sometimes the type of action affects what code is run.
Instead, it is much clearer to split this effect into two different effects:
validateForm$ = createEffect(() =>
this.actions$.pipe(
ofType(SaveButtonClicked),
withLatestFrom(this.store.select(FormSelector)),
map(([action, form]) =>
this.validationService.isValid(form)
? FormValidationSucceeded()
: FormValidationFailed()
)
)
);
saveForm$ = createEffect(() =>
this.actions$.pipe(
ofType(FormValidationSucceeded, ForceSaveClicked),
withLatestFrom(this.store.select(FormSelector)),
switchMap(([action, form]) =>
this.saveForm(form).pipe(
map(() => SaveFormSucceeded()),
catchError(() => of(SaveFormFailed()))
)
)
)
);
Now it is sharply clear that when SaveButtonClicked
, the form is validated, and either when FormValidationSucceeded
or ForceSaveClicked
the form is saved.
(Spoiler- this makes writing unit tests even easier for the effects)
When effects only do one thing, it makes it enjoyably easy to compose understandable chains of effects that occur based on easy-to-understand actions.
Let's do another thought exercise. What if we want to validate the form every time the form changes, not just when the user clicks save, so that we can let the user know that they need to fix something? We wouldn't want the save to occur every time FormValidationSucceeded
anymore.
Assuming our validate succeeded and failed actions result in the store being updated with the form's validity, then we can compose a new effect and modify the trigger of the existing save effect:
emitFormWasValidWhenSaveFormClicked$ = createEffect(() =>
this.actions$.pipe(
ofType(SaveFormClicked),
withLatestFrom(this.store.select(isFormValidSelector)),
filter(([action, isFormValid]) => isFormValid),
map(([action]) => FormWasValidWhenSaveFormClicked())
)
);
saveForm$ = createEffect(() =>
this.actions$.pipe(
ofType(FormWasValidWhenSaveFormClicked, ForceSaveClicked),
withLatestFrom(this.store.select(formSelector)),
switchMap(([action, form]) =>
this.saveForm(form).pipe(
map(() => SaveFormSucceeded()),
catchError(() => of(SaveFormFailed()))
)
)
)
);
We didn't have to touch the validateForm$
effect at all, and we didn't have to modify the internals of saveForm$
. It's so comfortable to make changes that it almost makes me feel sleepy...
But wait! I can't doze off yet, because there's one more thing keeping me awake at night.
#3 Emit only one action
Controversy! Yes, you will see other articles around the web that show the raw power of NgRx Effects by demoing returning an array of actions from a single effect.
But if you're following rules #1 and #2, then under what scenario would an effect result in multiple actions?
The answer is... none?
For the same reason you shouldn't dispatch multiple actions in a row in other places in your application, you shouldn't dispatch multiple actions from an effect because a single effect has a singular result.
Having multiple actions dispatched from an effect is likely a code smell that your actions are commands instead of representations of the events (read- actions) that have taken place in your system.
Final Thoughts
By keeping your effects concise and free of complication, you gain readability, composability, and testability. That's where the power of NgRx Effects shines.
I'm really feeling at ease now. I hope you are, too. Goodnight 💤.
(Oh hey, you really made it to the end? Check out this great eslint library that enforces other good practices into your NgRx Effects: https://github.com/timdeschryver/eslint-plugin-ngrx)
Posted on October 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.