Angular Standalone - NgRx State Management Architecture
Learn how to create a state management architecture with NgRx in a standalone Angular application using a facade pattern
State management is a crucial part of every Angular application. It helps to manage the UI state in a predictable way. Especially regarding a new upcoming way of structuring Angular applications in a standalone way, it is important to have a clear architecture for state management. This architecture is illustrated in this article using a practical example and can be used as a blueprint for projects of all kinds, both large and small.
Architecture
The data flow of NgRx based applications is unidirectional, meaning that the initial state change is triggered from the UI components, then some logic happens to update the state and saves it in the store and finally the UI components are informed about the change.
So that the components do not have to subscribe to the store directly, a facade is used. The facade is a service that provides a simplified interface to the store. The components can call methods on the facade to dispatch actions and select data from the store.
Effects are used to handle side effects like HTTP requests. They listen to actions dispatched to the store and can dispatch new actions based on the result of the side effect.
The data flow looks like this:
Example application
This article comes along with an example application to make it more practical. And to make it more funny we're going to build an entertainment application which provides random Chuck Norris jokes.
Preview
This is how the application looks like:
The full example application can be found on StackBlitz:
https://stackblitz.com/~/github.com/PKief/angular-ngrx-state-management-architecture
File structure
The file structure of the application could look like this:
📦src
┣ 📂admin
┣ 📂entertainment
┣ 📂shared
┣ 📂_state
┣ 📜app.routes.ts
┣ 📜index.html
┣ 📜main.ts
┗ 📜styles.scss
We put everything which is related to entertainment part of our application into the entertainment
folder. This includes the components, services, and state management. There we define a "_state" folder which contains the NgRx related files, "components" folder for the components, and "services" folder for the services. It's also recommended to have a shared folder for shared components, services, and models.
📦entertainment
┣ 📂components
┣ 📂services
┣ 📂_state
┗ 📜entertainment.routes.ts
Getting started
To set up a new Angular application based on standalone components, this command can be used:
ng new my-app --standalone --strict --routing
NgRx is added to the project:
ng add @ngrx/store@latest
ng add @ngrx/effects@latest
ng add @ngrx/store-devtools@latest
The entry file of the application is the main.ts
file. Here the application is bootstrapped and the necessary services like the store, effects, and router are provided. The App
component is the root component of the application and contains the RouterOutlet
to display the different routes. The App
component is marked as standalone, which means that it is not part of the Angular module system and can be used without the need of having a module. The OnPush
change detection strategy is used to improve performance.
Finally, the main.ts
file should look like this:
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<router-outlet />`,
})
export class App {}
bootstrapApplication(App, {
providers: [
provideStore(), // NgRx store
provideState(appFeature), // NgRx state provided by the app feature
provideEffects([AppEffects]), // NgRx effects for side effects (e.g. HTTP requests)
provideHttpClient(), // Angular HTTP client
provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)), // Angular router with preloading strategy
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }), // NgRx store devtools
],
});
State management
In the following sections, the state management will be defined and each part of it will be explained. The state management consists of actions, reducers, effects, facade, and state and is represented in the _state
folder like this:
📦_state
┣ 📜entertainment.actions.ts
┣ 📜entertainment.effects.ts
┣ 📜entertainment.facade.ts
┣ 📜entertainment.reducer.ts
┗ 📜entertainment.state.ts
There's also a _state
directory in the root directory of the application which was already shown in the general file structure at the beginning of the article. It contains the same file but instead of "entertainment" it is named "app". This is because it represents all global state which shall be used in the whole application (e.g. user authentication, internationalization, or a global loading spinner). In the following this article will focus on the entertainment part of the application.
State
At first the state is defined. The state is a type that describes the shape of the state. It contains all the properties that are needed to manage the state of the application. In this example, the state consists of an array of jokes, a boolean flag to indicate if the data is loading, and an error message. In addition, an initial state is defined which is used to initialize the state when the application starts.
import { Joke } from '../../shared/models';
// Shape of the state
export type EntertainmentState = {
jokes: Joke[];
isLoading: boolean;
error: string;
};
// Initial state to initialize the state when the application starts
export const initialEntertainmentState: EntertainmentState = {
jokes: [],
isLoading: false,
error: '',
};
Actions
Actions are used to describe what happened in the application. They are simple objects with a type property and an optional payload (or props) property. The type describes the action. The props can either be empty or contain additional data for the action to change the state.
The actions are defined and exported as an action group like this:
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Joke } from '../../shared/models';
export const entertainmentActions = createActionGroup({
source: 'ENTERTAINMENT',
events: {
// Jokes
loadRandomJoke: emptyProps(),
loadRandomJokeSuccess: props<{ joke: Joke }>(),
loadRandomJokeFailure: props<{ error: any }>(),
},
});
For the actions, the createActionGroup
function from the NgRx library is used. This function creates a group of actions with a common source. The source is a string that describes the source of the actions. The events are the actions that can be dispatched to the store. Each event has a name and a payload type. The payload type is a TypeScript type that describes the payload of the action.
Reducers
For the reducer a feature is created. The feature contains the name of the feature and the reducer function. The benefit of a feature is that it can be easily added to the store with the provideState
function in the main.ts
file. In addition, a feature automatically creates a selector for the feature state. Is there for example a state called "jokes", then a "selectJokes" selector is created automatically.
The reducer function itself is created with the createReducer
function from the NgRx library. The reducer function takes the initial state and a list of action handlers. Each action handler is a function that takes the current state and the action and returns the new state. In this example it is demonstrated how to handle the actions loadRandomJoke
, loadRandomJokeSuccess
, and loadRandomJokeFailure
:
import { createFeature, createReducer, on } from '@ngrx/store';
import { entertainmentActions } from './entertainment.actions';
import { initialEntertainmentState } from './entertainment.state';
export const entertainmentFeature = createFeature({
name: 'entertainment',
reducer: createReducer(
initialEntertainmentState,
on(entertainmentActions.loadRandomJoke, (state) => ({
...state,
isLoading: true,
})),
on(entertainmentActions.loadRandomJokeSuccess, (state, { joke }) => ({
...state,
jokes: [...state.jokes, joke],
isLoading: false,
})),
on(entertainmentActions.loadRandomJokeFailure, (state, { error }) => ({
...state,
error,
isLoading: false,
})),
),
});
Try to keep the reducer functions as simple as possible. They should only handle the state changes and not contain any business logic. The business logic should be placed in the effects.
As already mentioned, the feature automatically creates a selector for the feature state. But sometimes it's necessary to create additional selectors. This can be achieved by using the extraSelectors
property of the feature. In this example, a selector is created to select amount of jokes from the state:
export const entertainmentFeature = createFeature({
name: 'entertainment',
reducer: //...,
extraSelectors: ({ selectJokes }) => ({
selectJokesCount: createSelector(selectJokes, (jokes) => jokes.length),
}),
});
As shown in the facade section a bit later in this article, there's an easier way to handle additional selectors. Instead of creating a new selector in the feature, the computed
function of Angular can be used in the facade to provide additional data.
Effects
Effects are used to handle side effects like HTTP requests. They listen to actions dispatched to the store and can dispatch new actions based on the result of the side effect. The effects are defined as a class with methods that return observables. On top of the effect class the necessary dependencies like the Actions
service and the EntertainmentService
are injected. The addNewJokeToTheList$
effect listens to the loadRandomJoke
action and calls the loadRandomJoke
method of the EntertainmentService
. If the request is successful, a loadRandomJokeSuccess
action is dispatched. If an error occurs, a loadRandomJokeFailure
action is dispatched.
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { EntertainmentService } from '../services';
import { entertainmentActions } from './entertainment.actions';
@Injectable()
export class EntertainmentEffects {
private readonly actions$ = inject(Actions);
private readonly entertainmentService = inject(EntertainmentService);
addNewJokeToTheList$ = createEffect(() =>
this.actions$.pipe(
ofType(entertainmentActions.loadRandomJoke),
switchMap(() =>
this.entertainmentService.loadRandomJoke().pipe(
map((joke) => entertainmentActions.loadRandomJokeSuccess({ joke })),
catchError((error) => of(entertainmentActions.loadRandomJokeFailure({ error }))),
),
),
),
);
}
Typically, effects are the most complex part of the state management. Here's a solid knowledge of RxJS operators needed to properly handle the asynchronous side effects.
Here's a summary of the most important RxJS operators used in the effects:
switchMap
: Maps each value to an observable, then flattens all of these inner observables into a single observable. It is often used here to cancel the previous request if a new request is made. This is important to avoid inconsistencies in the state.map
: Applies a given project function to each value emitted by the source observable and emits the resulting values as an observable. That means in this example that once a new joke is loaded from the entertainment service, the joke is mapped to a new action (loadRandomJokeSuccess
) which is automatically dispatched to the store.catchError
: Catches errors on the observable and replaces them with a new observable. In this example, if an error occurs during the HTTP request, a new action (loadRandomJokeFailure
) is dispatched to the store.
Sometimes these operators can also be helpful:
tap
: Perform a side effect for every emission on the source observable. It is often used for logging or debugging purposes, but should be used with caution because it can lead to side effects. All side effects should be handled by returning new actions. Example for logging:tap((value) => console.log(value))
.mergeMap
: Sometimes it's necessary that an effect dispatches multiple actions. In this case,mergeMap
can be used. It maps each value to an observable and merges all of these inner observables into a single observable. Example:mergeMap(() => [action1(), action2()])
. But be aware, that both actions are dispatched at the same time and not sequentially.concatMap
: Similar tomergeMap
, but it dispatches the actions sequentially. That means that the second action is dispatched after the first action is completed. Example:concatMap(() => [action1(), action2()])
.debounceTime
: Delays the emission of values from the source observable by a given time. It is often used to avoid multiple requests in a short time. Example:debounceTime(300)
.
Basically, all RxJS operators can be used here. For more information about RxJS operators, please refer to the official documentation.
Same as for the reducer, try to keep the effects as simple as possible. It's better to create more effects with a single responsibility than to create one effect with multiple responsibilities. This makes the effects easier to test and understand.
Facade
The facade is the layer which provides a simplified interface to the store. The components can call methods on the facade to dispatch actions and select data from the store. It is injected into the components and services that need to interact with the store.
At first, the store instance is injected into the facade. Then the selectors are defined. In our example, the selectors are used with Signals. Signals are a way to select data from the store in a more reactive way. The jokes$
, error$
, and isLoading$
signals are defined to select the jokes, error message, and loading state from the store. Finally, the loadRandomJoke
method is defined to dispatch the loadRandomJoke
action.
@Injectable({ providedIn: 'root' })
export class EntertainmentFacade {
private readonly store = inject<Store<EntertainmentState>>(Store);
readonly jokes$ = this.store.selectSignal(entertainmentFeature.selectJokes);
readonly error$ = this.store.selectSignal(entertainmentFeature.selectError);
readonly isLoading$ = this.store.selectSignal(entertainmentFeature.selectIsLoading);
// Select Observable instead of Signal:
// readonly jokes$ = this.store.select(entertainmentFeature.selectJokes);
loadRandomJoke() {
this.store.dispatch(entertainmentActions.loadRandomJoke());
}
}
As shown in the example, it's still possible to stick with Observables also for selecting data from the store. But Signals are built into Angular directly and make is easier to work with the events from the store than working with Observables. With this approach you can keep the complex RxJS operators in the effects and use the simple Signals in the components.
Sometimes it's necessary to provide additional data in the facade. If for example the count of jokes would be needed in multiple components, we don't have to create a separate selector for it. We can use the computed
function of Angular instead to provide the data:
export class EntertainmentFacade {
// ...
readonly jokesCount$ = computed(() => this.jokes$().length);
// ...
}
In the components, only the facade is injected to handle all parts of the state management. This makes the components more lightweight and easier to test.
Here's an example of the EntertainmentComponent
which uses the EntertainmentFacade
to select the jokes and the loading state from the store and to dispatch the loadRandomJoke
action:
export class EntertainmentComponent {
readonly jokes$ = this.entertainmentFacade.jokes$;
readonly error$ = this.entertainmentFacade.error$;
readonly isLoading$ = this.entertainmentFacade.isLoading$;
constructor(private entertainmentFacade: EntertainmentFacade) {}
tellJoke() {
this.entertainmentFacade.loadRandomJoke();
}
}
The template of this component looks like this:
@if (isLoading$()) {
<app-loading-spinner />
}
<h1>Entertainment</h1>
<button (click)="tellJoke()">Tell me a joke</button>
@for (joke of jokes$(); track $index) {
<p>{{ joke.value }}</p>
}
Conclusion
On the example of the entertainment application, a clear architecture for state management with NgRx was shown. Together with Signals and standalone components, the state management can be handled in a very efficient way. The components are lightweight and easy to test and the whole state management is abstracted away by the facade. By using only Signals in the components, both the complexity of the RxJS operators is gone and the rerendering of the UI is working much better. Well-known Errors like "Expression has changed after it was checked" are avoided and the components are more reactive to the state changes.
Building Angular applications like this - regardless of the size of the application - is a good way to keep the codebase clean and maintainable. Even if the application grows, it's no problem to add new features and new domains to the application. Some people might say that NgRx is too complex for small applications, but I've made the experience quite often, that out of a sudden your client finds new budget to add new features. And to change the architecture of an application afterwards is much more complex than to start with a good architecture from the beginning (I've already done that as well and know what I'm talking about).
And regarding the point that some people might say, that NgRx brings too much boilerplate and slows you down: I think that's not true. If you have a good architecture, you can build features very fast. And if you have to change something, you know exactly where to change it. You don't have to search for the right place in the codebase. And if you have to add a new feature, you can be sure that you don't break anything else in the application. I've made the experience that you can be super fast with this approach and this is why I was sharing my knowledge with you in this article.
Example application
Find a full example application with the architecture described in this article on StackBlitz:
https://stackblitz.com/~/github.com/PKief/angular-ngrx-state-management-architecture