Revisiting State Management
The topic of state management in apps is something developers can spend countless hours discussing and never agree on what the "right" solution is. Truth is, there are so many solutions out there these days and they all share similar concepts that you can't really pick wrong. This is true in most frontend frameworks, but especially true in Angular, where the framework has introduced several new features in the past year that make managing state simpler than ever. Let's take a look at some approaches to state management in Angular that take advantage of modern features of the framework and can set us up for success in 2025.
Signals Only, No Library
In recent versions of Angular, the framework has introduced a new primitive called Signals. Signals provide a way to store a value, update that value, and react to when that value changes. This sounds like features that you would get from a full featured state management library. The benefit of using Signals without any additional libraries is that you can make things work for you how ever you want. If you have opinions on how you want to construct your state and manage updates, raw Signals can accommodate this.
1({
2 template: `
3 <p>Hello, {{ name() }}</p>
4 <button (click)="updateName()">Update</button>
5 `,
6})
7export class MessageComponent {
8 name = signal('World');
9 constructor() {
10 effect(() => {
11 console.log('Name has changed: ', this.name());
12 });
13 }
14 updateName() {
15 this.name.set('Mike');
16 }
17}
18
The built-in methods on the signal
make creating a simple local store very easy to do. If you take Signals and utilize a service, you create your own mini-store.
1export class AppStore {
2 readonly state = signal([]);
3
4 add(item) {
5 this.state.update((oldState) => [...oldState, item]);
6 }
7 delete(item) {
8 this.state.update((oldState) => oldState.filter((e) => e.id !== item.id));
9 }
10 update(item) {
11 this.state.update((oldState) =>
12 oldState.map((e) => (e.id === item.id ? item : e))
13 );
14 }
15}
16
Now this is just a very basic approach to managing state, but if you do not want to reach for an additional libray, you could get pretty far with this basic solution. Throw a few effect
s in there if you need to perform some side effects, and you're golden.
Signal State
If you've been around Angular long enough, you've probably reached for NgRx before, and with good reason. NgRx provides a standard way of managing state in your app that is scalable and testable. In the past, NgRx has provided a store solution based on RxJS, but in more recent releases, NgRx provides two new API based on Signals, Signal State and Signal Store.
Signal State is a lightweight API meant to be used in smaller, more isolated scenarios, where a full redux-like API isn't needed. This could be in small to medium sized apps, and in the component itself or extracted to a service.
Reworking our previous example, we can take our signal-based store and update it to use Signal State:
1import { patchState, signalState } from '@ngrx/signals';
2export class AppStore {
3 readonly state = signalState<Store>({ items: [] });
4
5 addToStore(item: StoreItem) {
6 patchState(this.state, (oldState) => ({
7 ...oldState,
8 items: [...oldState.items, item],
9 }));
10 }
11 removeFromStore(item: StoreItem) {
12 patchState(this.state, (oldState) => ({
13 ...oldState,
14 items: oldState.items.filter((e) => e.id !== item.id),
15 }));
16 }
17 updateStore(item: StoreItem) {
18 patchState(this.state, (oldState) => ({
19 ...oldState,
20 items: oldState.items.map((e) =>
21 e.id === item.id ? { ...item, name: 'bar' } : e
22 ),
23 }));
24 }
25}
26
Instantly, some things should stand out to you. First, we use the new signalState
function, instead of the raw signal
API. Now we can have a more type safe mechanism for interacting with our state. Second, we're no long passing an array. signalState
only accepts an object/record like value. If you need an array, you put that on a property of the state.
Finally, the way we interact with our state is different. Instead of manipulating the state directly, we use the patchState
function instead. patchState
takes the state we want to manipulate, and uses a function to return a new version of that state. To add a new item to our items
object, we can simply use the spread operator. Removing an item means we use filter
, and updating an item means we use map
. What's great about this is not only are we doing things in an immutable way, we're also getting all the types from our state. If we pass along a type that our state doesn't recognize, we'll get a type error before we even save.
Signal Store
So Signal State is a more prescriptive way of handling smaller state, be it in a component or service. What is Signal Store all about? Signal Store is the more robust solution that you would expect for NgRx. It still is based on Signals, keeping the structure that most larger teams would want for this state solutions. Again, let's rework our previous example and update it for Signal Store.
1const AppStore = signalStore(
2 withState<Store>({
3 items: [],
4 }),
5 withMethods((state) => ({
6 addToStore(item: StoreItem) {
7 patchState(state, (oldState) => ({
8 ...oldState,
9 items: [...oldState.items, item],
10 }));
11 },
12 removeFromStore(item: StoreItem) {
13 patchState(state, (oldState) => ({
14 ...oldState,
15 items: oldState.items.filter((e) => e.id !== item.id),
16 }));
17 },
18 updateStore(item: StoreItem) {
19 patchState(state, (oldState) => ({
20 ...oldState,
21 items: oldState.items.map((e) =>
22 e.id === item.id ? { ...item, name: 'bar' } : e
23 ),
24 }));
25 },
26 }))
27);
28
Here, we're starting to see a more structured approach to managing state that isolates our state interactions from the rest of our app. Instead of creating a service, we use the signalStore
function instead. signalStore
will return an injectable service instead that we provide to a component, or our root app instance. From here, we pass a withState
function to provide any actual state value to the store. Like signalState
, this is an object/record only.
For modifying our store, we can use the withMethods
function and pass any methods we want to expose to our app. What stands out here is that our store's value is accessible without having to inject it. Similar to signalState
, we use the patchState
to make any changes we need. Since the mechanism to modify the store in signalStore
is very close to what we had in signalState
, it's very approachable when migrating from your simple local store to something more full featured. So if your app and team grow significantly, this is a great path forward.
Parting Thoughts
If you've tried managing state using something like NgRx or other redux-inspired APIs, signal-based solutions are a breath of fresh air. Whether you are just building a small app and just want to use the raw signal API, or if you are in a large enterprise and want a structured approach to managing things, Signal State or Signal Store are both excellent solutions. Check out the Angular docs on the Signals or NgRx's docs on Signal State or Signal Store
Also, make sure to check out: