Photo by Shutter Speed on Unsplash
State management in Angular has come a long way. From raw Observables in global services, to modern and modular stores such as the signal store, passing by heavier Redux implementations: the Angular ecosystem has seen many ways of managing state.
However, since the last few major releases, Angular now also has a different kind of reactive variable with signals
. With them, we now have a synchronous and reactive way to interact with and react to our data, things that were previously possible with a decent DX mainly through state management solutions.
In this article, we will explore how to manage state without a third party library with a practical use case, and implement the Redux pattern step by step. If you would like to follow along, you can head to the dev.local-state-management
repository on GitHub.
Why Managing State At All?
If you already are familiar with state management, you can jump to the next section "Implementing State Management for a Shopping Cart".
In enterprise applications, we often hear about state management, but why do we have to do so? Isn't it simpler and straightforward to have properties in components and interacting with them from here?
Well, yes, and in fact it's an absolutely viable solution for small components, or even small projects. State management is a tool, and like every tool, it helps solving a specific issues such as sharing data across multiple components, keeping the complexity away from multiple components, allowing components to focus on the UI and not the logic, and so on.
If you're not familiar with it, a lot of things in software engineering often falls into the Law of the instrument, which can be summarized as "If your only tool is a hammer then every problem looks like a nail". In this case, state management is not always necessary as its a tradeoff between increased complexity and data consistency.
Moreover, it also allows for simpler tests: if all your logic is abstracted in a store then, when testing your components, you only have to mock the store to test the component's behavior.
A brief introduction to Redux
State management often follows the Redux pattern.
Made popular as a React library back in 2015, Redux is a library and a pattern to manage state in predictable manner.
It consist of a couple of logical groups, each with their own responsibility, allowing for the data to flow only in one way:
- The component consumes the state that wraps the data through selectors that exposes parts of it.
- It can trigger actions to communicate that something has happened.
- Actions will then be captured by pieces of logic that will translate them to an update of the state. There sinks are called reducers.
- By updating the state, reducers will cause the selectors to update, and thus update the view.
- Additionally, side effects can be triggered upon being notified of an action.
Here is a more graphical view of how this all falls into place:
Managing State in an Angular Application
In Angular, managing state is commonly done with a library, usually with NgRx, NGXS or rxAngular to only cite some of them. However, picking a library is more about preferences since they are all doing a pretty good job at providing building blocks for you to manage your application's state.
But since Angular signals
, since started to change, and we now can leverage this reactive primitive to gracefully manage our state in a Redux-like way, without depending on third party library.
Let's see how!
Implementing State Management for a Shopping Cart
Case Study: Le Shop
For this section, we will work on introducing a state management system to the following french food shopping website:
Here are the actions that can currently be performed:
- A user can see its cart, with the cumulated total
- A user can add items to its cart by clicking the
Add to Cart
button - A user can clear its cart
For now, all of this is done in the parent component:
@Component({
selector: 'app-root',
imports: [ProductCardComponent, CartComponent],
template: `
<h2>Le Shop 🇫🇷</h2>
<div>
<app-cart [cart]="cart()" (clear)="clearCart()" />
@if (productsResource.value(); as products) {
<section>
@for (product of products; track product.name) {
<app-product-card [product]="product" (addedToCard)="addItem(product)" />
}
</section>
}
</div>
`,
styles: `...`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
readonly productsService = inject(ProductsService);
readonly cart = signal<CartItem[]>([]);
readonly productsResource = resource({
loader: this.productsService.getProductsStock,
});
addItem(product: Product): void {
this.cart.update((cartItems) => {
const currentQuantity = cartItems.find(({ name }) => name === product.name)?.quantity ?? 0;
return cartItems
.filter(({ name }) => name !== product.name)
.concat([{ ...product, quantity: currentQuantity + 1 }]);
});
}
clearCart(): void {
this.cart.set([]);
}
}
Analyzing the Current Structure
As we said earlier, if the scope doesn't grow and the complexity is manageable, having this kind of code is fine.
However, if new use cases arise, things can be a bit harder to keep organize. For instance, look back at the code, and think of how you would successively add:
- A toast to notify the user of the product he just added
- A way to apply discounts
- A button to be notified of when a product that is currently out of stock becomes available
Apart from the business cases, this also presents some technical downsides since this component has a lot of responsibilities (managing the cart logic, presenting the data, ...).
Since most of the logic lives here, it would not be possible to share it with other components either. If one day we had to add a small cart icon in the navbar with the current total, we would have a hard time doing so.
In summary, this component:
- Will be more and more bloated as it supports more and more use cases
- Mixes business logic with UI orchestration
- Is harder to test than it could be
- Can't easily share its state
Fortunately, we know what Redux is, and are about to introduce a brand new store!
Creating the Store
To create our store, we will focus on each part one after the other, starting by the state.
Defining the State
Before writing any code, we have to identify what our state is currently made of. On the top of our head, we can identify a few properties:
- The current loading status
- The listed products
- The added items
We could also have add an error message if the loading failed, but for the sake of the example, we will keep it short here, feel free to implement it!
We can now translate the contract defining our state, and wrap it in a signal
to be able to synchronously access it, but also to react to a change if needed:
export interface AppState {
addedProducts: CartItem[];
isLoading: boolean;
products: ListedProduct[];
}
@Injectable()
export class AppStore {
readonly #state = signal<AppState>({
addedProducts: [],
isLoading: false,
products: [],
});
}
That's a great start, but we don't have any way to actually interact with our store, let's work on it.
Adding Events
Events are things that happened. They often have to properties:
- They are asynchronous, and can usually be triggered in any order, at any time
- They carry a payload, that can be identical if the same thing is emitted (for instance, if we add the same product twice, it would result in the same event being fired twice, with the same product)
Since events are asynchronous and can be identical, Observables
are a good fit to represent them. We can represent those events as emitted by those sources.
This could also have been done with
signals
by overriding the equality check to always be false, and thus allowing for the same payload to be sent twice. However, I am personally not found of this way of representing sources since it kind of defies one of the primary properties ofsignals
.
In our case, we have a few things that can be represented as events:
- The cart is cleared
- A product is added to the cart
- Products are loaded
Each of them can be emitted, with their payload, by the following sources:
readonly #cartCleared = new Subject<void>();
readonly #productAddedToCart = new Subject<{ product: Product }>();
readonly #productsLoaded = new Subject<{ products: ListedProduct[] }>();
readonly #productsLoading = new Subject<void>();
Did you notice? All events are using the past tense. This is a way of representing that something has happened.
However, we don't want to expose these properties since they are part of how the store is orchestrating with its state. For instance, we don't want a Component
to instruct the store of which products were loaded, since it's not its responsibility.
We can instead expose a public API to allows only some actions to be triggered by the consumers. This is what is called actions:
addProductToCart(product: Product): void {
this.#productAddedToCart.next({ product });
}
clearCart(): void {
this.#cartCleared.next();
}
We can now emit event, but we still are not interacting with the state, and that's where reducers come into play.
Creating Reducers
Reducers are pieces of code that listen for events, and update the state depending of their payload.
It's important to notice that they are the only part of the store that can temper with the state.
Since our events are represented as Observables
, we can write our reducers as Subscriptions
to each of our events:
constructor() {
this.#cartCleared
.pipe(takeUntilDestroyed())
.subscribe(() => this.#state.update((state) => ({ ...state, addedProducts: [] })));
this.#productAddedToCart.pipe(takeUntilDestroyed()).subscribe(({ product }) =>
this.#state.update((state) => {
const currentQuantity =
state.addedProducts.find(({ name }) => name === product.name)?.quantity ?? 0;
const updatedCart = state.addedProducts
.filter(({ name }) => name !== product.name)
.concat([{ ...product, quantity: currentQuantity + 1 }]);
return { ...state, addedProducts: updatedCart };
}),
);
this.#productsLoaded
.pipe(takeUntilDestroyed())
.subscribe(({ products }) =>
this.#state.update((state) => ({ ...state, products, isLoading: false })),
);
}
Almost there! Unfortunately, since the components cannot tell which products are loaded, we need a way to trigger this programmatically.
Introducing Effects
Simply put, effects are events that trigger others. Applied to our application, we can think of the resource
's change of loading state or value as events, and trigger the appropriate events accordingly.
Let's first grab the resource:
readonly #productsService = inject(ProductsService);
readonly #productsResource = resource({
loader: this.#productsService.getProductsStock,
});
We can now react to the changes, and propagate the desired event:
effect(() => {
const products = this.#productsResource.value();
if (products) {
this.#productsLoaded.next({ products });
}
});
effect(() => {
const isLoading = this.#productsResource.isLoading();
if (isLoading) {
this.#productsLoading.next();
}
});
Funny thing that the
signals
package is exposing aneffect
method that is great for us to .. propagate effects!
And just like that, our state is now holding the business logic of our application, with the data always flowing in one direction:
- User actions or effects can trigger actions
- actions emit events
- events are captured by reducers
- reducers update the state
Our state is now looking great, with just a small issue: there is no way to read the state from an external consumer.
Exposing Selectors
Selectors are ways to reactively expose parts of the state.
For instance, we want to access what the content of the cart is, and see its content updated dynamically.
Fortunately, our state is already reactive since it's a signal
, and derived, read only, parts of a signal
, is the textbook definition of a computed
.
This makes the selectors fairly easy to write:
readonly addedProducts = computed(() => this.#state().addedProducts);
readonly products = computed(() => this.#state().products);
We are not using the
isLoading
property in this example, but feel free to expand the example with your own usages!
Our store is now successfully implementing the Redux pattern. If we take a look back at the previous diagram, here it is, applied to our case:
With a functioning store, and the proper ways to interact with it, we can now remove the business logic of our component with the store we just created.
Using The Store
Before using our store, remember to provide it just like any other service. In this example, it will be provided at the AppComponent
level, as its component store. For global state, you can of course use @Injectable({ providedIn: 'root' })
or directly provide it in the appConfig
.
@Component({
selector: 'app-root',
imports: [ProductCardComponent, CartComponent],
template: `...`,
styles: `...`,
providers: [AppStore], // 👈 Provided here
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
readonly store = inject(AppStore);
// ...
}
Now that it's provided, we can replace all the business logic with our store's properties:
@Component({
selector: 'app-root',
imports: [ProductCardComponent, CartComponent],
template: `
<h2>Le Shop 🇫🇷</h2>
<div>
<app-cart [cart]="store.addedProducts()" (clear)="clearCart()" />
@if (store.products(); as products) {
<section>
@for (product of products; track product.name) {
<app-product-card [product]="product" (addedToCard)="addItem(product)" />
}
</section>
}
</div>
`,
styles: `...`,
providers: [AppStore],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
protected readonly store = inject(AppStore);
addItem(product: Product): void {
this.store.addProductToCart(product);
}
clearCart(): void {
this.store.clearCart();
}
}
Our component now only focuses on orchestrating the UI, and is stripped of any business logic's considerations.
With this refactor, we addressed the issues we faced:
- If we were to test the component, we would only have to mock the
AppStore
- If we had to share the state, we could provide the store higher in the hierarchy and consume it elsewhere
- If we had to add a new feature, the store would wrap that logic and integrate it with the existing state
Takeaways
In this article, we explored what Redux is, what problems it solves, and how to implement this pattern in an Angular application without introducing a third-party library.
Beyond simplifying our code, this approach brings clear separation of concerns: components focus solely on UI orchestration while the store handles all business logic. This separation makes testing significantly easier, as we can test the store's logic independently and mock it entirely when testing components.
The state holds our application data in a reactive signal
, while actions provide a public API for triggering changes. Events represent things that have happened and are emitted through Subjects
, which reducers listen to as the sole updaters of state. Effects handle side effects and trigger other events, and selectors expose reactive, read-only slices of state through computed
signals.
State management often feels like something reserved for complex applications with heavy dependencies, but as we've seen, it's actually quite accessible. By leveraging Angular's native signals
and RxJS Observables
, we built a fully functional Redux-like store using nothing more than the tools that Angular already provides.
This doesn't mean that libraries like NgRx, NGXS, or rxAngular don't have their place. They offer additional features, conventions, and tooling that can be valuable for larger teams or more complex scenarios. However, understanding that you can achieve effective state management with "simple" Angular code empowers you to make informed decisions about when a library is truly necessary and when the framework's built-in capabilities are more than sufficient for your needs.
Top comments (0)