NgRx can feel overwhelming, especially for developers coming from object-oriented backgrounds. The Redux paradigm represents a significant shift from conventional programming styles, which has led to various efforts to make NgRx more approachable through Angular-like APIs or to create entirely different libraries. The Facade pattern is one such attempt, while Akita and NgXs are alternative libraries that emerged from similar motivations. When our team evaluated NgRx, there was an initial lean toward the Facade Pattern to shield the team from the boilerplate and the perceived complexity of NgRx. However, the originators of NgRx did not endorse the original version of this pattern, as it violates the principles of Good Action Hygiene (GAH?). Sam Julian published a thoughtful analysis of the pros and cons of the Facade approach, offering his own variation. That looked more like a stopgap measure than a real solution, since you'd still be writing a considerable number of Actions and Selectors. In this article we'll examine several simple wrappers around NgRx that will: 1. Align Reducers with the Open/Closed principle 2. Enforce the "Actions as Events" mindset strictly 3. Minimize the number of Actions required while preventing Action reuse, and therefore 4. Enable the use of Facades without compromising GAH.
Principles of Good Action Hygiene
The core guidelines are:
- Avoid Action reuse – Dispatch a dedicated Action for each source. The takeaway: View Actions as one-of-a-kind events in the system, not as commands.
- Steer clear of generic Action types – Following on from #1, using specific Action names lets you trace the origin of each dispatch through the Redux Dev Tools or by inspecting reducers. The takeaway: Keep Actions traceable through source code and Store Dev Tools.
- Prevent Action sub-typing – This occurs when you define a broad Action type and attach extra fields (e.g., a
kindproperty) to convey the specific handling requirements. This tends to lead to numerous nested conditionals across the application to interpret those details. The takeaway: Use narrow Action types.
If you were paying attention, you'll notice that #1 and #2 are precisely the rules the conventional Facade pattern violated.
These guidelines make sense, yet a problem remains:
The SOLID Conflict in Good Action Hygiene
Take the food ordering app from Mike Ryan's GAH presentation, where users can add Tacos or Burgers from two different pages. There are two actions corresponding to these two sources:
[Menu Page] Add Taco
[Taco Details Page] Add Taco
The upshot is that your reducer must be aware of both distinct actions.
@Injectable()
class TacoEffects {
@TacoEffects() addOne$ = this.actions$.pipe(
ofType(
'[Menu Page] Add Taco',
'[Taco Detail Page] Add Taco'
),
mergeMap(action =>
this.tacoService.addOne(action.taco).pipe(...))
)
}
This example highlights that the Effect's duty is rarely tied to the Action's source; it has the same job no matter where it comes from.
Imagine you add a homepage banner advertising Tacos. Now you need a new Action: [Taco Ad] Add Taco. Your Reducers and Effects would then require updates to accommodate this new Action. Yes, the effort is trivial. Yet we're violating the Open/Closed Principle. If you follow best practices of naming Actions by their source, you can't keep Reducers and Effects closed to modification.
The solution is straightforward.
Renaming Actions as Events
The first move is to be exact about what Actions really are. For those with OOP experience, the term "Action" often brings Commands to mind. Good Action Hygiene (GAH?) was designed to shift this perspective, pushing us to view Actions as Events. Let's take that one step further. Let's simply treat them as Events.
export interface Event extends Action {
readonly verb: string;
readonly source: string;
[other: string]: any;
}
The Action's type is now divided into a source and a verb. The verb represents the actual event, for instance "adding a Taco". The source is where that event originated.
Wait — isn't this precisely "Action sub-typing" that we're told to avoid? Not exactly. The real concern is sub-typing in a way that spreads conditional logic throughout the codebase, especially in reducers. As we'll see, that issue doesn't arise with this method.
Creating Events
Next, we need a factory for these events. Keep in mind that our Event type is derived from Action, so it remains a standard Action from the standpoint of NgRx.
export function createEvent<P>(
source: string,
verb: string,
config?: P
) {
if (!config) {
return () => ({ verb, source, type: `[${source}] ${verb}` })
}
return (prop: P) => ({
...prop,
verb,
source,
type: `[${source}] ${verb}`
});
}
Why not also add a prepareEvent function to generate a "prepared event" that can be called with just a source and arguments, without repeating the verb each time?
export function prepareEvent(verb: string);
export function prepareEvent<ArgsType>(verb: string, config: ArgsType);
export function prepareEvent<ArgsType>(
verb: string,
config?: ArgsType
){
if (!config) {
const assembler = (source: string) => toEvent(source, verb);
((assembler as any) as VerbedEvent).verb = verb;
return assembler;
} else {
const assembler = (source: string, prop: ArgsType) => ({
...prop,
verb,
source,
type: `[${source}] ${verb}`,
});
((assembler as any) as VerbedEvent).verb = verb;
return assembler;
}
}
Writing Reducers
Now, the ideal reducer would look like this:
export const ordersReducer = createEventReducer(
initialState,
on(OrderEvents.tacoAdded,
(state, event) => /* ... */),
on(OrderEvents.burgerAdded,
(state, event) => /* ... */)
);
To make that happen, we create our own versions of onType and on(...) functions as shown:
export interface On<StateType> {
reducer: ActionReducer<StateType, Event>;
verb: VerbType;
}
export function on<StateType>(
verb: VerbType,
reducer: ActionReducer<StateType>
): On<StateType, VerbType> {
return { verb, reducer };
}
export function createEventReducer<StateType>(
initialState: StateType,
...ons: On<StateType>[]
): ActionReducer<StateType, Event> {
const map = new Map<
VerbType,
ActionReducer<StateType, Event>
>();
for (let on in ons) {
map.set(ons[on].verb, ons[on].reducer);
}
return function(
state: StateType = initialState,
action: Event
): StateType {
const reducer = map.get(action.verb);
return reducer ? reducer(state, action) : state;
};
}
Dispatching Events
At this stage, you have all the pieces needed to dispatch and handle events. This is how you'd dispatch one:
const tacoAddedFromMenu = createEvent(
'Menu Page',
OrderEvents.tacoAdded,
props<{ taco: Taco }>
);
// NOTE: We do need a custom props function and a Props type here.
// See the sample or the linked gist at the end.
/* ... */
this.store.dispatch(tacoAddedFromMenu({ taco }));
Alternatively, using prepared events:
const tacoAdded = prepareEvent(
OrderEvents.tacoAdded,
props<{ taco: Taco }>
);
// NOTE: We do need a custom props function and a Props type here.
// You can find the code for them in the attached gist or the sample repo.
/* ... */
this.store.dispatch(tacoAdded('Menu Page', { taco }));
/* ... */
this.store.dispatch(tacoAdded('Taco Detail Page', { taco }));
Handling Effects
To support Effects, let's add a straightforward event filter named onEvent:
export function onEvent(
expectedEvent: VerbType
): OperatorFunction<Action, Event> {
return flatMap((action: Action) =>
(action as Event).verb === expectedEvent
? of(action as Event)
: EMPTY
);
}
Our earlier example now looks like this:
@Injectable()
class TacoEffects {
@TacoEffects() addOne$ = this.actions$.pipe(
onEvent(OrderEvents.tacoAdded),
mergeMap(
action =>
this.tacoService.addOne(action.taco).pipe(/* ... */))
)
}
The Event Store
It's not essential, but you could subclass the NgRx Store to add convenience methods that streamline event dispatch:
public dispatch(event: Action): void;
public dispatch<VerbType extends string>(
source: string,
verb: VerbType
): void;
public dispatch<VerbType extends string>(
source: string,
verb: VerbType,
args: any
): void;
Considering Facades
For those fond of the Facade Pattern, you can now adopt it without the pitfall of reusing Actions [2]. Just leverage prepared events and expose methods for each event.
@Injectable({ providedIn: 'root' })
export class OrderingFacade {
/** selectors... */
private readonly tacoAdded = prepareEvent(
OrderEvents.tacoAdded,
props<{ taco: Taco }>
);
private readonly burgerAdded = prepareEvent(
OrderEvents.burgerAdded,
props<{ burger: Burger }>
);
constructor(private store: EventStore<OrderState>)
public tacoAddedFrom(source: string, taco: Taco) {
this.store.dispatch(this.tacoAdded(source, { taco }));
}
public burgerAddedFrom(source: string, burger: Burger) {
this.store.dispatch(this.burgerAdded(source, { burger }));
}
}
Demonstration Project
To showcase this pattern, I adapted the NG Conf 2020 Workshop Sample created by the NgRx Team. The changes are available on the complete branch of a fork from the original repository.
Wrapping Up
Essentially, our only real change was splitting Action.type into separate source and verb properties. The rest is supporting code for that shift. There's no groundbreaking invention here — just a modest adjustment.
What it accomplishes, though, is a mental reset: Actions are finally treated as the Events they were meant to be. We understand that events, even similar, represent distinct flows when they arise from different origins. The rule against reusing them becomes second nature rather than a learned constraint.
Additionally, we've succeeded in keeping Reducers and Effects closed to modification while the application remains open for expansion. Reducers and Effects now only react to event kinds, regardless of their origin, all without infusing extra checks into our logic.
The full implementation source is in this gist or in the sample project [1].
References
- [1] In the Gist, I've named
onaswhenandpropsasargsto avoid clashes with built-in NgRx operations. In this article, I kept the original names for familiarity with NgRx experts while underscoring the main ideas. - [2] The Facade Pattern for NgRx has also been scrutinized for potential "Selector Abuse". That topic is outside the scope of this article. In general, caution your team against the Facade Pattern unless you are consistently reviewing code for such misuse.
