NgRx in Practice: Making Decisions Across Your Application
Cover photo originally by Hansjörg Keller on Unsplash.
Earlier pieces in this series looked at how NgRx can guard access and manage collections. This time, we turn to a broader concern: deciding what happens next throughout an Angular application, relying primarily on NgRx Effects (with selectors and reducers playing supporting roles). We’ll walk through:
- Managing errors with NgRx
- Redirecting users effectively
- Loading data without friction
- Responding to user actions
Time to dive in.
Managing failures in an NgRx application
Nobody enjoys dealing with errors, and it is all too common to postpone or overlook this part of development. Still, every application genuinely depends on solid failure handling. In an NgRx-powered project, the difficulty around errors grows unless we tackle it deliberately. Failures typically surface in effects, and those are, in most cases, the result of HTTP calls.
Broadly speaking, error management follows two philosophies: local and global. The local strategy deals with a particular failure in a specific section of the app. For example, when a login attempt fails, we might prefer displaying a precise message such as "Invalid username or password" rather than a vague one like "Something went wrong".
The global strategy, on the other hand, channels all failures through one central flow and relies on those broad, generic messages mentioned earlier. One could debate which approach fits which situation, but the truth is that nearly every application benefits from a mix of both. We need to notify the user when any problem occurs—since even a basic message beats total silence—while still allowing room for specialized actions in response to certain failures. Let's dive into how to achieve both.
A unified approach to error notifications
In NgRx, every event that occurs starts with an action. For any operation that could lead to a failure—like HTTP calls, WebSockets, and similar—it is standard practice to define several actions around that single operation:
export const DataActions = createActionGroup({
source: 'Data',
events: {
'Load Data': emptyProps(),
'Load Data Success': props<{ data: Data }>(),
'Load Data Error': props<{ error: string }>(),
},
});
As demonstrated, pulling data from an API alone requires three separate actions. In a larger application, we might end up with dozens—or even hundreds—of these "failure" and "success" actions. To avoid repeating ourselves, it makes sense to trigger a generic error message whenever any of those failure actions are dispatched. One way to achieve this is by giving all error actions a consistent payload structure. For instance, using a shared error property in the action payload can serve as a reliable indicator that the action represents a failure.
With that in place, we can listen to every action that carries an error and show a standard notification. This is a widely used technique in NgRx applications and is commonly referred to as "global error handling". In our setup, we accomplish this by subscribing to all actions and then filtering out those that contain an error property in their payload:
export const handleErrors$ = createEffect(() => {
const actions$ = inject(Actions);
const notificationsService = inject(NotificationsService);
return actions$.pipe(
filter((action) => !!action.payload.error),
tap((action) => {
notificationsService.add({
severity: 'error',
summary: 'Error',
detail,
});
}),
}, { functional: true, dispatch: false });
In this setup, every error action we dispatch results in the same notification being shown, albeit with a custom message. We can take this a step further by standardizing the way we define the props for error actions. Here's a convenient helper function that simplifies this process:
export function errorProps(error: string) {
return function() {
return({error});
};
}
Now we can employ this function to craft the error props for our actions:
export const DataActions = createActionGroup({
source: 'Data',
events: {
'Load Data': emptyProps(),
'Load Data Success': props<{ data: Data }>(),
'Load Data Error': errorProps('Failed to load data'),
},
});
This approach keeps every error consistent, reducing the chances of typos or misunderstandings. Now, let's enhance this mechanism to accommodate more specific error scenarios.
Tailoring the response to particular failures
There will be situations where we'd like to adjust the behavior of our standard error handling for certain cases. Specifically, we might want to:
- instruct the effect whether or not to display a generic error notification
- navigate to dedicated error pages, along with some predefined information
- show an error alert directly within the page context
Let's start with the first requirement. We can achieve this by introducing a new field to the error action payload:
export function errorProps(error: string, showNotififcation = true) {
return function() {
return({error, showNotification});
};
}
Now, we can define an action that will later signal the effect to bypass the standard notification:
export const DataActions = createActionGroup({
source: 'Data',
events: {
'Load Data': emptyProps(),
'Load Data Success': props<{ data: Data }>(),
'Load Data Error': errorProps('Failed to load data', false),
},
});
Next, we should modify the effect to accommodate this new option:
export const handleErrors$ = createEffect(() => {
const actions$ = inject(Actions);
const notificationsService = inject(NotificationsService);
return actions$.pipe(
filter((action) => !!action.payload.error),
tap((action) => {
if (action.payload.showNotification) {
notificationsService.add({
severity: 'error',
summary: 'Error',
detail,
});
}
}),
);
}, { functional: true, dispatch: false });
Notice that we intentionally didn't include the showNotification check within the filter operator. That's because there may be cases where we don't want a notification, but we still need something else to happen—like navigating to an error page. Let's address that by adding another parameter to our error action:
export function errorProps(error: string, showNotification = true, redirectTo?: string) {
return function() {
return({error, showNotification, redirectTo});
};
}
With this in place, we can create an action that will instruct the effect to navigate to an error page:
export const DataActions = createActionGroup({
source: 'Data',
events: {
'Load Data': emptyProps(),
'Load Data Success': props<{ data: Data }>(),
'Load Data Error': errorProps('Failed to load data', false, '/error'),
},
});
Now, let's complete our effect by adding the logic to redirect to an error page whenever the redirectTo property is present in the action payload:
export const handleErrors$ = createEffect(() => {
const actions$ = inject(Actions);
const notificationsService = inject(NotificationsService);
const router = inject(Router);
return actions$.pipe(
filter((action) => !!action.payload.error),
tap((action) => {
if (action.payload.showNotification) {
notificationsService.add({
severity: 'error',
summary: 'Error',
detail,
});
}
if (action.payload.redirectTo) {
router.navigateByUrl(action.payload.redirectTo);
}
}),
);
}, { functional: true, dispatch: false });
And that covers it. Of course, if a particular error action demands something truly unique, we can always write a separate effect dedicated to handling that specific scenario. Sometimes, when we want the UI to react to an error, we can also store the error message (and any other relevant data) in the store and access it via a selector from anywhere.
Now, let's move on to another important topic: loading data into our components and the different strategies available for doing so.
Managing data loading
Let's clarify upfront that none of the techniques discussed in this section should be considered inherently superior. These are simply different tools suited for different UX requirements. We'll walk through each option in turn.
Reading data directly from the component
The simplest way to access data, presumably fetched from an API, is to select it directly within the component. With the modern APIs, you can create a signal from your selected data and consume it straight in the template. A minimal example looks like this:
@Component({
selector: 'app-my',
template: `
<div>
<h1>Data</h1>
<p>{{ data() }}</p>
</div>
`,
})
export class MyComponent {
data = this.store.selectSignal(dataFeature.selectData);
}
Naturally, production scenarios usually involve additional concerns like loading indicators and error handling. In such cases, the store shape often evolves to something like this:
export interface State {
data: Data | null;
loading: boolean;
error: string | null;
}
When you register your state via the createFeature function, you automatically get a selectDataState selector. This selector returns the full slice of state, exposing the loading, error, and data properties. The component can then use it in the following manner:
@Component({
selector: 'app-my',
template: `
<div>
@if (vm().loading) {
<p>Loading...</p>
}
@if (vm().error) {
<p>Error: {{ vm().error }}</p>
} @else {
<h1>Data</h1>
<p>{{ vm().data }}</p>
}
</div>
`,
})
export class MyComponent {
vm = this.store.selectSignal(dataFeature.selectDataState);
}
This pattern works well for the majority of situations. However, there are times when you want to prevent the entire page from rendering until a critical piece of data has arrived. In Angular, that's traditionally accomplished with route resolvers — functions that return Observables which the router waits for before navigating to the destination. With a plain HttpClient this is straightforward. With NgRx, though, it gets trickier since HTTP requests belong inside effects, which leads many developers to abandon resolvers entirely. A viable workaround exists nonetheless. The following resolver combines the Store with the Actions Observable to detect when loading has finished:
export const dataResolver: ResolveFn<Data[]> = () => {
const store = inject(Store);
const actions$ = inject(Actions);
store.dispatch(DataActions.loadData());
return store.select(dataFeature.selectData).pipe(
skipUntil(actions.pipe(ofType(DataActions.loadDataSuccess))),
);
}
Here we dispatch the action that triggers the HTTP request first. Then we return the relevant slice of the store as an Observable, but we instruct it to wait until the action that signals a successful data load has been dispatched. Since effects are guaranteed to fire after reducers, this ordering guarantees the data is already committed to the store when the resolver delivers it. The component can then simply subscribe to this result:
@Component({
selector: 'app-my',
template: `
<div>
<h1>Data</h1>
<p>{{ vm.data() }}</p>
</div>
`,
})
export class MyComponent {
private readonly route = inject(ActivatedRoute);
readonly vm = toSignal(this.route.data, {
initialValue: null,
}) as Signal<{data: Data}>;
}
Notice we now rely on ActivatedRoute rather than the Store, because the data was already fetched during navigation. This approach further slims down the component — the Store doesn't even need to be injected. In terms of unit testing, mocking the ActivatedRoute often turns out simpler than mocking the Store.
To wrap up, we'll explore more sophisticated decision-making patterns with NgRx actions and effects, and see how they can untangle complex scenarios found in larger codebases.
Handling user-driven flows
NgRx shines in declarative setups—you pick the slice of state you need and bind it straight into the template. But there are moments, particularly with third-party widgets, where a bit of imperative glue is unavoidable. Take the Angular Material MatDialog service and a typical confirmation prompt:
export class MyComponent {
private readonly dialog = inject(MatDialog);
private readonly store = inject(Store);
openConfirmationDialog() {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
title: 'Confirmation',
message: 'Are you sure you want to do this?',
},
});
dialogRef.componentInstance.confirm.subscribe(() => {
this.store.dispatch(DataActions.deleteData());
});
dialogRef.componentInstance.cancel.subscribe(() => {
dialogRef.close();
});
}
}
That snippet is heavy on imperative setup. Two subscriptions appear, neither of them trivial, and we haven't even touched cleanup logic. In a real app, the same confirmation dialog might pop up in a dozen places, each differing only in what happens after the user clicks confirm or cancel.
Let's rework it through an NgRx lens, attempting to encapsulate the whole flow in a single action, with callbacks riding along as payload:
export function confirmAction(callbacks: {confirm: () => void, reject: () => void}) {
return function() {
return({type: 'Open Confirmation Dialog', callbacks});
};
}
From there we can define a separate action that instructs an effect to reroute to an error page:
export const DataActions = createActionGroup({
source: 'Data',
events: {
'Delete Data': confirmAction({
confirm: () => {
return({action: 'Delete Data Confirmed'});
},
reject: () => {
return({action: 'Delete Data Rejected'});
},
}),
},
});
Next, we write an effect that responds to the whole family of such actions:
export const handleConfirmationDialog$ = createEffect(() => {
const actions$ = inject(Actions);
const dialog = inject(MatDialog);
return actions$.pipe(
ofType(DataActions.openConfirmationDialog),
tap((action) => {
const dialogRef = dialog.open(ConfirmationDialogComponent, {
data: {
title: 'Confirmation',
message: 'Are you sure you want to do this?',
},
});
dialogRef.componentInstance.confirm.subscribe(() => {
action.payload.callbacks.confirm();
});
dialogRef.componentInstance.cancel.subscribe(() => {
action.payload.callbacks.reject();
});
}),
);
}, { functional: true, dispatch: false });
Finally, the component collapses to almost nothing:
export class MyComponent {
private readonly store = inject(Store);
openConfirmationDialog() {
this.store.dispatch(DataActions.openConfirmationDialog({
confirm: () => {
this.store.dispatch(DataActions.deleteData());
},
reject: () => {
// Do nothing
},
}));
}
}
That looks neat—but a snag appears if we want to keep the codebase as strict as possible. A widely encouraged NgRx practice is keeping everything serializable, meaning it can be trivially turned into JSON. The framework offers two flags to defend against, say, stashing functions in the store: strictStoreSerializability and strictActionSerializability.
export const config: ApplicationConfig = {
providers: [
provideStore({}, {
runtimeChecks: {
strictActionSerializability: true,
strictStoreSerializability: true,
},
}),
};
Those checks pay off in the long run, heading off subtle bugs before they surface.
[!NOTE] The NgRx documentation covers runtime checks in more detail—see the official docs.
But here's the rub: with strict action serializability on, our confirmAction and its callbacks are a non-starter. The fix? Supply nested actions for the confirm and cancel branches, and let the effect dispatch them. Since those inner actions must also be serializable, everything stays above board. I've taken to calling this pattern "higher-order actions."
export function confirmAction(confirmAction: string, rejectAction: string, callbackActions: {
confirm: ActionCreator<any, any>,
reject: ActionCreator<any, any>
}) {
return function() {
return({type: 'Open Confirmation Dialog', callbackActions});
};
}
Now the effect needs a substantial overhaul:
export const handleConfirmationDialog$ = createEffect(() => {
const actions$ = inject(Actions);
const dialog = inject(MatDialog);
return actions$.pipe(
ofType(DataActions.openConfirmationDialog),
map(({callbackActions}) => {
const dialogRef = dialog.open(ConfirmationDialogComponent, {
data: {
title: 'Confirmation',
message: 'Are you sure you want to do this?',
},
});
return merge([
dialogRef.componentInstance.confirm.pipe(
map(() => callbackActions.confirm()),
),
dialogRef.componentInstance.cancel.pipe(
tap(() => dialogRef.close()),
map(() => callbackActions.reject()),
),
])
}),
);
}, { functional: true, dispatch: false });
Let's walk through the logic:
ConfirmationDialogComponentexposes two observables:confirmandcancel.- We launch a
MatDialogusing that component. Whenever aconfirmActionarrives, we subscribe toconfirmand dispatch the action bundled intoconfirmAction. - Similarly, we subscribe to
canceland dispatch the paired reject action. - The effect returns a
mergeof both observables, so the effect emits whenever either branch fires.
This arrangement lets us choreograph intricate decisions from a component with only a handful of dispatched actions:
export class MyComponent {
private readonly store = inject(Store);
openConfirmationDialog() {
this.store.dispatch(DataActions.openConfirmationDialog({
confirm: DataActions.deleteData,
reject: DataActions.cancelDeleteData,
}));
}
}
That's the whole approach. With higher-order actions, decision logic can be pushed downstream into other effects and reducers without muddying the component, keeping it purely declarative.
Wrapping up
This piece walked through several ways to tame complex logic in Angular apps with NgRx. The framework offers a wide canvas, and it's often possible to reshape unwieldy code into something clear and maintainable. These techniques don't get nearly enough attention—hopefully this gives you a few new tools for your state management toolkit.
