Comparing Angular State Management: NgRx Classic, Signal Store, and the Events Plugin
Comparing Angular State Management: NgRx Classic, Signal Store, and the Events Plugin
For me, state management is an important aspect of building scalable and maintainable Angular applications. In the past, NgRx has been my go-to state management solution for Angular applications, helping me to combine the power of RxJS to handle state changes, side effects and getting the state under control.
With the introduction of Signals in Angular, there are now new tools, like the Signal Store and the new Signal Store Events Plugin. These tools attempt to simplify state management by providing slightly new patterns. In this blog post, we will compare the classic NgRx store with the new Signal Store and the Events Plugin, by looking at their key features, advantages, and best uses. Whether you require a more traditional solution or like to dive into newer, more reactive patterns, this comparison will help you make an informed decision for your next Angular project.
The Sample Application
The sample application we will have as an example is a bookshop where you have an overview of all books at the start page as well as book details. You can place the books in a shopping cart and checkout then.



The folder structure is simple. There is a checkout
feature where all the books added in the cart are being displayed, as well as a products
feature showing all the books and a product-detail
feature which shows the details of a product. All modules are lazy loaded except the shared
and the shell
part.

app/
├── features/
│ ├── checkout/
│ │ ├── container/
│ │ ├── presentational/
│ │ └── state/
│ ├── product-detail/
│ │ ├── container/
│ │ ├── presentational/
│ │ ├── service/
│ │ └── state/
│ ├── products/
│ │ ├── container/
│ │ ├── presentational/
│ │ ├── service/
│ │ └── state/
│ ├── shell/
│ │ ├── container/
│ │ └── presentational/
│ └── shared/
│ ├── models/
│ ├── pipes/
│ ├── state/
│ └── theme/
└── app.component.html/ts/...
Overview of Each Solution
NgRx Classic
Let us take a look at the application with the classic NgRx store. This means, we have the traditional way of using actions as messages, effects for having side effects, a reducer which is manipulating the state, selectors which provide us slices of the state and composes derived information of the state and the state itself as an object.
File wise, we can separate it like this:
app/
└── features/
├── checkout/
│ └── ...
├── product-detail/
│ └── ...
├── products/
│ ├── ...
│ └── state/
│ ├── products.actions.ts
│ ├── products.effects.ts
│ ├── products.reducer.ts
│ ├── products.selectors.ts
│ └── products.state.ts
├── shell/
└── shared/
- Actions: Are like messages which can be sent around. They can contain information but do not have to.
- Effects: Are there for async side effects, like an http call. They can react to an action and return a new action, once their async operation is finished.
- Reducer: Is the only place we can manipulate the state object. The reducer knows the old state, reacts to an action and returns a new state object.
- Selectors: can provide us slices of the state and derived values from the state
Let us take a look at some actions first:
export const CheckoutActions = createActionGroup({
source: '[Checkout]',
events: {
'Add Product': props<{ product: Product }>(),
'Remove Product': props<{ index: number }>(),
},
});
We can define an actionGroup
in which we have a source
which is the prefix of all those actions and helping us grouping them together as well as events
which represents the specific actions we can send around with or without content.
If we look at the state itself, we have an eager loaded checkout
state where we can react to the actions.
export type CheckoutState = {
cartProducts: Product[];
};
export const initialCheckoutState: CheckoutState = {
cartProducts: [],
};
export const checkoutReducer = createReducer(
initialCheckoutState,
on(CheckoutActions.addProduct, (state, { product }) => ({
...state,
cartProducts: [...state.cartProducts, product],
}))
// ...more actions
);
As the state is eager loaded we can provide it on root level in the ApplicationConfig
of Angular.
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideStore({
[checkoutFeatureKey]: checkoutReducer,
}),
provideEffects(),
],
};
Which makes our state look like
{
"checkout": { "cartProducts": [] }
}
In the beginning.
We lazy load parts of the application and therefore attach parts of the state when the corresponding part of the app is loaded:
export const routes: Routes = [
{
path: "",
component: ShellComponent,
children: [
{
path: "products",
loadComponent: () => import("...").then(...),
providers: [
provideState(productsFeatureKey, productsReducer),
provideEffects(productsEffects),
],
},
{
path: "products/:id",
loadComponent: () => import("...").then(...),
providers: [
provideState(productDetailFeatureKey, productDetailReducer),
provideEffects(productDetailEffects),
],
},
// ...
{
path: "**",
pathMatch: "full",
redirectTo: "products",
},
],
},
];
Which, at the end, lets our state look like
{
"checkout": { "cartProducts": [] },
"productDetail": { "product": {} },
"products": { "products": [] }
}
If all parts of the application are loaded.
Implementing features with NgRx Classic always follows a simple pattern:
- After a trigger (Button click of a component, Web Socket message in the system, etc.) an action gets dispatched.
ngOnInit(): void {
this.store.dispatch(ProductsActions.loadProducts());
}
- Effects and reducer can listen to that action. The reducer manipulates the state immediately (e.g. setting a
loading
totrue
) and an effect can trigger an async action (e.g. an http call) - When http call is finished, the effect dispatches a new action with maybe some information as result of that Http request.
export const loadProducts$ = createEffect(
(actions$ = inject(Actions),
productsService = inject(ProductsService)) =>
actions$.pipe(
ofType(ProductsActions.loadProducts),
exhaustMap(() =>
productsService.loadProducts().pipe(
map((products) =>
ProductsActions.loadProductsSuccess({ products })),
catchError((error: HttpErrorResponse) =>
of(ProductsActions.loadProductsFailure({ error }))
)
)
)
),
{ functional: true }
);
- Reducer again listens to this action and manipulates the state with the information passed with the action.
export const productsReducer = createReducer(
initialProductsState,
on(ProductsActions.loadProductsSuccess, (state, { products }) => ({
...state,
products,
}))
);
As the components only “listen” to state changes and only reflect what is on the state object, they only represent the information which are currently on the state.
@Component({
// ...
})
export class ProductsComponent implements OnInit {
private readonly store = inject(Store);
readonly products = this.store.selectSignal((state) => state.products);
ngOnInit(): void {
this.store.dispatch(ProductsActions.loadProducts());
}
}
With selectors
we can transform the information we have on the state and derive values from it, for example grouping products by its categories, without adding a new state property for that.
const featureSelector =
createFeatureSelector<ProductsState>(productsFeatureKey);
export const selectProducts = createSelector(
featureSelector,
(state: ProductsState) => state.products
);
export const selectProductsByCategories = createSelector(
selectProducts,
(products) => {
const productsByCategory = products.reduce(
(result: Record<string, Product[]>, product) => {
const { category } = product;
if (!result[category]) {
result[category] = [];
}
result[category].push(product);
return result;
},
{}
);
const categories = Object.keys(productsByCategory);
return categories.map((category) => ({
category,
products: productsByCategory[category],
}));
}
);
And our component can use this selector afterwards
@Component({
// ...
})
export class ProductsComponent implements OnInit {
private readonly store = inject(Store);
readonly productsByCategories = this.store.selectSignal(
selectProductsByCategories,
);
ngOnInit(): void {
this.store.dispatch(ProductsActions.loadProducts());
}
}
It is nice to see that our component has no business logic at all and only dispatches actions which then trigger a direct or indirect state change:
@Component({
// ...
})
export class ProductsComponent implements OnInit {
private readonly store = inject(Store);
readonly productsByCategories = this.store.selectSignal(
selectProductsByCategories,
);
ngOnInit(): void {
this.store.dispatch(ProductsActions.loadProducts());
}
onProductClicked(id: string): void {
this.store.dispatch(ProductsActions.navigateToDetail({ id }));
}
onCartClicked(product: Product): void {
this.store.dispatch(CheckoutActions.addProduct({ product }));
}
}
The NgRx classic approach forces us to write the most code, often referred to as “boilerplate”. On the other hand, this provides us a maximized separation of concerns. With this approach it is absolutely clear for every team member which part of a feature has to be implemented where and can often be copy/pasted and modified. The pattern behind it is always the same which makes it easy to implement, test and review. Your developers so can concentrate on the problems which are specific to your application. Also, getting into the code as a new member and maintaining the code is a lot easier as the pattern is always the same. Furthermore, migrating from the RxJS async
pipe to signals was a breeze by using store.selectSignal(...)
instead of store.select(...)
. Signals were used in the direction of the UI whereas the complete business logic, which was implemented in effects, services, sometimes reducers and selectors was either not async or, if async, completely relaying on RxJS and got complete separated.
NgRx Signal Store
Let us take a look at the NgRx Signal Store. After Angular introduced signals, the NgRx team wanted to provide a solution which was completely using signals as much as possible.
While the sense of state management stayed the same, the concepts differ a lot.
The “too much boilerplate code” argument was heard, and it was reduced to a minimum. The heavy focus on RxJS was removed and made optional, so that we also can use promises for our async operations.
We could also see that the classic NgRx was moving its effects to a more functional approach (examples above) and so was the complete signal store approach written with functional in mind.
In addition to that the complete store was made facade-like, providing everything to the injected place as we can see in the following example.
Although you can separate the files and folders to your need, the original approach of state management with the NgRx signal store can be seen in one file.
While the particular state itself stays the same
type ProductsState = {
products: Product[];
};
const initialProductsState: ProductsState = {
products: [],
};
The management of the state is much different, as we can create a state with the signalStore(...)
method.
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState)
);
The result ProductsStore
then is injectable to the component, for example and can be used there.
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.products() }}`
})
export class ProductsComponent {
readonly store = inject(ProductsStore);
}
All the state properties, products
in this example, are already available as a signal here.
But where did our actions go? How do me manipulate the state? Where are the effects, and where are the selectors?
Our actions, which were our messages we passed around in the NgRx classic approach, went to direct method calls which we can implement as such in another method the signal store provides us which is called withMethods(...)
.
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withMethods(
(store, productsService = inject(ProductsService)) => ({
loadProducts: rxMethod<void>(
exhaustMap(() =>
productsService.loadProducts().pipe(
tapResponse({
next: (products) => patchState(store, { products }),
error: (error: HttpErrorResponse) =>
console.error('Failed to load products.', error.message),
}),
),
),
),
}),
),
);
We still can inject the ProductsService
as we did before in the effects and implement a method loadProducts()
directly in the withMethod(...)
extension on the ProductsStore
.
As the signal store also is a facade, we can use this method directly over the ProductsStore
in our component.
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.products() }}`
})
export class ProductsComponent implements OnInit {
readonly store = inject(ProductsStore);
ngOnInit(){
this.store.loadProducts();
}
}
What we also used in the ProductsStore
is a method called patchState()
which we also get from the signal store. This method is replacing our “reducer” we had in the classic NgRx approach. The method patchState()
receives the state as a first argument and a partial state as a second, where we can pass in the changes to the state directly. No need for an effects file as well as a reducer file like see in the classic approach.
We can also use plain promises here for the same functionality:
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withMethods(
(store, productsService = inject(ProductsService)) => ({
async loadProductsByPromise() {
const products = await lastValueFrom(productsService.loadProducts());
patchState(store, { products })
},
// other methods...
}),
),
);
Usable in the same way:
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.products() }}`
})
export class ProductsComponent implements OnInit {
readonly store = inject(ProductsStore);
ngOnInit(){
this.store.loadProductsByPromise();
}
}
What we are missing now is the selectors in the NgRx classic approach. They were perfect to create derived values from a state and not pollute our components with that.
The Angular team added a computed
method for signals which is exactly for that: If you want to have derived values from an information of your state, you can use this when using signals.
The NgRx team got us a method withComputed(...)
for this which we can implement and use equivalent to the withMethods(...)
method. It provides us derived values from our state. Like the selectors in the NgRx classic approach. Like the computed
method in plain Angular.
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withComputed((store) => ({
productsByCategories: computed(() => {
const products = store.products();
const productsByCategory = products.reduce(
(result: Record<string, Product[]>, product: Product) => {
const { category } = product;
if (!result[category]) {
result[category] = [];
}
result[category].push(product);
return result;
},
{},
);
const categories = Object.keys(productsByCategory);
return categories.map((category) => ({
category,
products: productsByCategory[category],
}));
}),
})),
withMethods(
(store, ...) => ({
...
}),
),
);
The withComputed(...)
method extends the state again, and we can use it over the facade:
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.productsByCategories() }}`
})
export class ProductsComponent implements OnInit {
readonly store = inject(ProductsStore);
ngOnInit() {
this.store.loadProductsByPromise();
}
}
So our reducer went into a patchState
method directly being used in the signal store. Our effects went into a method in the withMethods
feature, and our selectors went into a withComputed
feature.
Lifecycle Hooks
The NgRx signal store goes a little bit further here and provides two lifecycle hooks onInit
and onDestroy
which are being called when the service is being created and erased.
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withComputed((store) => ({
// ...
})),
withMethods(
(store, productsService = inject(ProductsService)) => ({
// ...
}),
),
withHooks({
onInit: (store) => store.loadProducts(),
onDestroy(store) {
console.log('Destroy', store....);
},
}),
);
As we call the loadProducts
now on the onInit
lifecycle, we can erase it from the component ngOnInit
then
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.productsByCategories() }}`
})
export class ProductsComponent {
readonly store = inject(ProductsStore);
}
Nice and clean.
Important: The lifecycle hooks of the store are to be treated like any other lifecycle methods. If the store is provided on component level, the store is being created with the component and being destroyed with the component, e.g. when navigating away from it and removing it from the DOM.
However, if you put a store as { providedIn: 'root' }
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withState<ProductsState>(initialProductsState)
);
The store is being created on the root injector once it is used for the first time in the app. The onInit
hook on the state is called once, and it is never destroyed, so the onDestroy
never gets called. But you can call cleanup methods of the state through the components’ lifecycle methods still.
Custom Features
Where the Signal Store really shines are its custom features which are reusable then. So withComputed
, withMethods
etc. are enhancing the state facade we use from the components’ perspective.
We can create our own withXyz
methods and plug them into our state! All we have to do is returning a signalStoreFeature
and use the same withComputed
, withMethods
, etc. as before.
An example is a category feature, where I need categories for some entries in multiple states. So I designed a small state for the categories, and a method I want to have available everywhere I use this feature. The state properties are available as well, of course.
export type CategoryState = {
categories: Category[];
selectedCategory: Category | null;
isLoading: boolean;
};
const initialState: CategoryState = {
categories: [],
selectedCategory: null,
isLoading: false,
};
export function withCategoryFeature() {
return signalStoreFeature(
withState<CategoryState>(initialState),
withMethods((store, categoryService = inject(CategoryService)) => ({
loadCategories: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true })),
exhaustMap(() =>
categoryService.getAll().pipe(
tapResponse({
next: (categories) =>
patchState(store, {
categories,
selectedCategory: categories[0],
}),
error: console.error,
finalize: () => patchState(store, { isLoading: false }),
})
)
)
)
),
}))
);
}
The method withCategoryFeature
is returning a signalStoreFeature
with the exact same methods withComputed
, withMethods
, etc. we already know. If we now plug this method into our existing state like this
type ProductsState = {
products: Product[];
};
const initialProductsState: ProductsState = {
products: [],
};
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withState<ProductsState>(initialProductsState),
withCategoryFeature() // Extending the state with methods, properties etc.
);
Our state is now extended with all the methods, properties and computed, which we do not have currently, from the custom feature.
@Component({
// ...
providers: [ProductsStore],
template: `
{{ store.productsByCategories() }}
{{ store.categories() }}
{{ store.selectedCategory() }}
`
})
export class ProductsComponent {
readonly store = inject(ProductsStore);
}
And we do NOT have to inject the category feature into the store itself.
NgRx Signal Store with Events Plugin
Now that we know the signal store as well as the classic NgRx approach, we have a new kid in town: The Events Plugin for NgRx SignalStore.
The Events Plugin brings us back many parts we already knew from the classic NgRx approach: Actions which are now called Events
, a dispatcher to dispatch those events as well as a method which acts like a reducer and a methoid which can handle side effects like effects.
The Events Plugin is a plugin for the signal store, so both can live side by side.
If you want to define events with the Events Plugin you can do so by using the eventGroup
method:
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
export const productsEvents = eventGroup({
source: '[Products]',
events: {
loadProducts: type<void>(),
loadProductsSuccess: type<Product[]>(),
},
});
If we now want to react to those events in the signal store we can do that as the following:
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withReducer(
on(productsEvents.loadProducts, () => ({ loading: true })),
on(productsEvents.loadProductsSuccess, ({ payload }) => ({
loading: false,
products: payload
})),
),
);
But this is, as we already know, only for syncronous things.
But where did our effects go? With them, we can manage async operations. Without the Events plugin this has been method calls which we could clal over the store facade.
The events plugin offers a withEffects
function which we can plug into our signalStore
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withReducer( /* ... */ ),
withEffects(
(
store,
events = inject(Events),
productsService = inject(ProductsService),
) => ({
loadProducts$: events
.on(productsEvents.loadProducts)
.pipe(
exhaustMap(() =>
productsService.loadProducts().pipe(
mapResponse({
next: (products) =>
productsEvents.loadProductsSuccess(products),
error: (error) =>
productsEvents.loadProductsFailure(error),
}),
),
),
),
}),
),
);
Seeing this is a slightly similar view as we had to the functional effects in the classic NgRx example but baked into the signal store.
When dispatching actions from NgRx Classic we already know this syntax:
this.store.dispatch(ProductsActions.loadProducts());
and the Events Plugin gets close to this: We can inject a dispatcher and dispatch Events through this instance:
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.productsByCategories() }}`
})
export class ProductsComponent {
readonly store = inject(ProductsStore);
readonly dispatcher = inject(Dispatcher);
ngOnInit() {
this.dispatcher.dispatch(productsEvents.loadProducts());
}
}
or we can go with the injectDispatch
and inject the events directly
@Component({
// ...
providers: [ProductsStore],
template: `{{ store.productsByCategories() }}`
})
export class ProductsComponent {
readonly store = inject(ProductsStore);
readonly productsEventsDispatch = injectDispatch(productsEvents);
ngOnInit() {
this.productsEventsDispatch.loadProducts();
}
}
The signal store really shines when we want to extract features like showed previously and the events plugin also makes this possible. We have to create a method retuning a signalStoreFeature
and then we can plug into this feature all the withComputed
, withMethods
we already used, but this is of course also possible with the withReducer
or withEffects
in case you want to re-use a reducer, effects etc.
This slightly goes into the direction I experimented with a few months ago here
Sample Repo NgRx Todo App
Where I extracted all the things to have a better overview of my state. Here, the Events Plugin is NOT involved.
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState<TodoState>(initialState),
withTodosSelectors(),
withTodosMethods(),
withHooks({
onInit({ loadAllTodosByPromise }) {
console.log('on init');
loadAllTodosByPromise();
},
onDestroy() {
console.log('on destroy');
},
}),
);
But we can achieve the same now with the Events Plugin and extracted reducers and effects.
We can take the reducer from above and create a method returning a signalStoreFeature
with a withReducer
Method
export function withProductsReducer() {
return signalStoreFeature(
{ state: type<ProductsState>() },
withReducer(
on(productsEvents.loadProducts, () => ({loading: true})),
on(productsEvents.loadProductsSuccess, (products) => ({
loading: false,
products
})),
),
);
}
and the effects
export function withProductsEffects() {
return signalStoreFeature(
withEffects(
(store, events = inject(Events),
productsService = inject(ProductsService),
) => ({
loadProducts$: events
.on(productsEvents.loadProducts)
.pipe(
exhaustMap(() =>
productsService.loadProducts().pipe(
mapResponse({
next: (products) =>
productsEvents.loadProductsSuccess(products),
error: (error) =>
productsEvents.loadProductsFailure(error),
}),
),
),
),
}),
),
);
}
which can be composed then as
export const ProductsStore = signalStore(
withState<ProductsState>(initialProductsState),
withProductsReducer(),
withProductsEffects(),
);
And there you have it: A nice separation, if you want, it is reusable because you can compose everything as you wish.
Summary: Comparing NgRx Classic, Signal Store, and the New Events Plugin
In this blog post, we compared three state management solutions NgRx provides. NgRx Classic, NgRx Signal Store, and the NgRx Signal Store with Events Plugin. We did focus on understanding their key features, advantages, and best use cases by using a bookshop application loading products.
When using NgRx Classic we had separate actions, reducers, and effects, with a clear separation of concerns. It’s a very mature solution, providing strong scalability and a very predictable flow. It also makes finding bugs very easy: If it’s wrong on the state object, the bug is when writing to the state (effects, reducers), if it is correct on the state, the bug is in reading from the state (selectors, components, …). However, it can be hard to grasp in the beginning and has a steeper learning curve. It is relying heavily on RxJS and a significant amount of code. Often similar code, but a significant amount still. NgRx’s classic state management is ideal for complex, large-scale applications where fine-grained control over state and side effects is essential.
NgRx Signal Store simplifies the complete pattern by eliminating much, if not all, of the boilerplate code by using a more functional approach. It uses a built-in facade pattern and Angular signals all the way. Further, it reduces the reliance on RxJS. Promises are usable without the consumer even noticing. The state is managed through a signalStore()
and state changes are handled directly within methods using patchState()
instead of reducers. Also, side effects are not explicitly mentioned as they merge into simple method calls. This makes the setup faster and the API more intuitive, especially for simpler applications or developers new to state management. Things are really getting interesting when features can be extracted and reused when composed together in a new state.
The NgRx Signal Store with Events Plugin introduces a plugin using a hybrid model that combines the simplicity of the Signal Store with familiar concepts from NgRx Classic, such as actions (now events
), effects and reducers. It allows developers to define eventGroups()
for actions, manage side effects with withEffects()
, and use a reducer to manipulate the state in a known form but new withReducer()
method. By having all the known patterns, this plugin does not forget to provide the ability to extract parts of the state, so it can be re-composed and re-used in other parts of the application state.
Key Differences and Similarities:
Effects: In NgRx Classic, effects are defined separately and respond to actions. In Signal Store, effects are integrated into withMethods()
and can be called like normal methods. The Events Plugin uses withEffects()
to manage asynchronous actions which can be implicitly called by events again.
Selectors: NgRx Classic uses a separate part for selectors. The Signal Store integrates selectors using withComputed()
providing the computed properties over the facade, while the Events Plugin also uses withComputed()
to provide derived state values.
Actions: NgRx Classic uses action groups, while Signal Store simplifies this by calling actions directly through methods. The Events Plugin restores action definitions but uses events rather than actions.
Reducer: In NgRx Classic, reducers are separate functions that return a new state. Signal Store manages state changes with patchState()
, and the Events Plugin integrates this with withReducer()
reacting to events again.
Overview of Differences and Similarities
Effects | Selectors | Actions | Reducer | |
---|---|---|---|---|
NgRx Classic | Separate class/functions reacting to an action, indirectly called through actions | Separate parts with methods to select slices of state/derived values, composable | Defined by actionGroups(…) with source and events | Separate part reacting to actions always returning a new state |
NgRx Signal Store | Implemented in withMethods(…), directly called over built in facade | withComputed() for derived values, directly callable over facade, composable | Actions are direct methods calls, callable over the facade | Done with the patchState(...) function inside the methods |
NgRx Signal Store With Events Plugin | Implemented in withEffects(...) , indirectly called by events | withComputed() for derived values, directly callable over facade | Events Defined by eventGroups(…) with source and events | Done with the withReducer(...) function which reacts to events, indirectly called by events |
Links
https://github.com/FabianGosebrink/ngrx-sample-store-app