NgRx Signals Store - Is It a NgRx Replacement?
In this post you will learn what is NgRx signals, how to use this library and what is the pros and cons of it.
Not so long ago inside NgRx 17 we got a new library which is called @ngrx/signals
.
For you it might be really confusing what is the goal of this library. We have things like @ngrx/store
which is a global NgRx, we also have a library @ngrx/component-store
and now we have ngrx/signals
.
You might think "Maybe ngrx/signals is a full replacement of ngrx/store but with signals". This is not true at all. Ngrx with state, reducers and effects are all working just like previously. Yes, sure, you can transform quite easily NgRx stream from the selector to the signals but it is not related to @ngrx/signals
library at all.
@ngrx/signals is an additional library which is not related to NgRx stor at all.
You can just think of it like a separate library in NgRx family. A goal of this library is to simplify for developers working with Angular signals. So it is not about NgRx at all, we are talking here only about Angular signals and how to simplify working with them.
NgRx Signals is a standalone library that provides a reactive state management solution and a set of utilities for Angular Signals.
It should be declarative, simple, modular, extensible and scalable.
Installation
The first step here is to install this library.
npm i @ngrx/signals
Inside package.json
we have now only @ngrx/signals
but not @ngrx/store
, @ngrx/effects
or any standard NgRx stuff. This is a completely isolated library which works just with signals.
I also prepared a interface for the post that we want to render
// src/app/posts/types/post.interface.ts
export interface PostInterface {
id: string;
title: string;
}
and a service which returns a list of posts with the delay.
// src/app/posts/services/posts.service.ts
@Injectable({
providedIn: 'root',
})
export class PostsService {
getPosts(): Observable<PostInterface[]> {
const posts = [
{ id: '1', title: 'First post' },
{ id: '2', title: 'Second post' },
{ id: '3', title: 'Third post' },
];
return of(posts).pipe(delay(2000));
}
}
Additionally I have a posts.component
with just a basic form to add posts.
<div>
<form [formGroup]="addForm" (ngSubmit)="onAdd()">
<input type="text" placeholder="Add..." formControlName="title" />
</form>
</div>
Here we can add a post but we don't have any logic yet. And here is our empty component.
export class PostsComponent {
fb = inject(FormBuilder);
postsService = inject(PostsService);
addForm = this.fb.nonNullable.group({
title: '',
});
}
We just injected here a service and created a form but nothing else.
Signal state
But just imagine that this is our posts component where we want to render a list of our posts from the API, we also want to show a loading indicator and an error message when we have some problems and have a possibility to add and remove our posts.
If we are doing it with signals we will typically create several signals just inside our component. Like signals for the list of posts, signal for loading state and signal for an error.
Inside @ngrx/signals
the idea is to simplify this logic and make it more declarative.
import {signalState} from '@ngrx/signals';
export class PostsComponent {
...
state = signalState({
posts: [],
error: null,
isLoading: false,
});
}
Here we used signalState
from the library to create multiple signals at once inside an object and provide a default state there. But we must do better because we need to provide a correct interface for our state.
export interface PostsStateInterface {
posts: PostInterface[];
isLoading: boolean;
error: string | null;
}
export class PostsComponent {
...
state = signalState<PostsStateInterface>({
posts: [],
error: null,
isLoading: false,
});
}
Now it is not possible to throw inside wrong values. Also we see on the glance the whole state of our component at one place.
Let's render some markup now.
...
<div *ngIf="state.isLoading()">Loading...</div>
<div *ngIf="state.error()">{{ state.error() }}</div>
<div *ngFor="let post of state.posts()">
{{ post.title }}
</div>
Here we just use signals like always but they are stored in a single object state
.
This is good but we don't have any posts yet so nothing is rendered. Let's implement onAdd
function to be able to create posts.
export class PostsComponent {
...
onAdd(): void {
const newPost: PostInterface = {
id: crypto.randomUUID(),
title: this.addForm.getRawValue().title,
};
const updatedPosts = [...state.posts(), newPost];
patchState(this.state, (state) => ({ ...state, posts: updatedPosts }));
this.addForm.reset();
}
}
Here we created newPost
with randomID and a title from our form. The we create new array with posts by arring new post to the end. After this we called patchState
function which allows us to update a state by returning new state.
patchState is an only way to update our state which is awesome as it keeps things strict.
So inside @ngrx/signals
we are getting a really nice utilities on top of the signals to work with them.
As you can see now we can add posts on our page.
Let's implement removing of the posts as a new feature.
{{ post.title }} <span (click)="removePost(post.id)">X</span>
Here in HTML we just provide an ID of the post that we want to remove.
removePost(id: string): void {
const updatedPosts = this.state.posts().filter((post) => post.id !== id);
patchState(this.state, (state) => ({ ...state, posts: updatedPosts }));
}
We do similar stuff with reading the signal first and then using patchState
to update our state. Now we can easily remove elements in browser.
signalState covers 70% of the cases that you might have with signals.
NgRx signals store
But if you have something more complex or you want to move state from component completely it is also possible. You can create a store outside of the component and access it inside.
export const PostsStore = signalStore(
withState<PostsStateInterface>({
posts: [],
error: null,
isLoading: false,
})
)
@Component({
...
providers: [PostsStore],
})
export class PostsComponent {
...
store = inject(PostsStore);
Here we created PostsStore
on the outside and injected it inside as a property store
. withState
function allows us to provide a state inside our store.
withComputed
But there is much more than that. For example we can also create computed properties for the component.
export const PostsStore = signalStore(
withState<PostsStateInterface>({
posts: [],
error: null,
isLoading: false,
}),
withComputed((store) => ({
postsCount: computed(() => store.posts().length),
})),
)
Here we created a property postsCount
which is a computed signal and it will be available in our component by accessing store.postsCount()
.
withMethods
But we still did not move addPost
and removePost
outside of the component in the store.
export const PostsStore = signalStore(
withState<PostsStateInterface>({
posts: [],
error: null,
isLoading: false,
}),
withComputed((store) => ({
postsCount: computed(() => store.posts().length),
})),
withMethods((store) => ({
addPost(title: string) {
const newPost: PostInterface = {
id: crypto.randomUUID(),
title,
};
const updatedPosts = [...store.posts(), newPost];
patchState(store, { posts: updatedPosts });
},
removePost(id: string) {
const updatedPosts = store.posts().filter((post) => post.id !== id);
patchState(store, { posts: updatedPosts });
},
})),
);
We used withMethods
where we can define methods of the store. I fully moved whole business logic of addPost
and removePost
except of form resetting to the store. As you can see we still used patchState
but provided a store inside and the data that we want to update.
But we must still change our html.
<div>
<form [formGroup]="addForm" (ngSubmit)="onAdd()">
<input type="text" placeholder="Add..." formControlName="title" />
</form>
<div>Total: {{ store.postsCount() }}</div>
<div *ngIf="store.isLoading()">Loading...</div>
<div *ngIf="store.error()">{{ store.error() }}</div>
<div *ngFor="let post of store.posts()">
{{ post.title }} <span (click)="store.removePost(post.id)">X</span>
</div>
</div>
Now we are using store
instead of state
everywhere. It is important that we used store.removePost
directly but onAdd
not because we need additional component logic inside. Let's do this now.
onAdd(): void {
this.store.addPost(this.addForm.getRawValue().title);
this.addForm.reset();
}
Here we just called addPost
from the store and resetted our form. So the whole business logic is moved outside and we just work with view logic.
RxJs Method
But it is not all. At some point to might want to fetch data from the API and set them in store. We can do it in easy way.
addPosts(posts: PostInterface[]) {
patchState(store, { posts });
}
...
ngOnInit(): void {
this.postsService.getPosts().subscribe((posts) => {
this.store.addPosts(posts);
});
}
Here we created a method addPosts
in our store which just sets a list of posts inside. Now we can use this method together with subscribe to update our state. This is an amazing approach that I like to use.
But we can do it differently. We can write RxJS code of fetching data inside the store and call getting data directly after initializing of the store.
export const PostsStore = signalStore(
withState<PostsStateInterface>({
...
}),
withComputed((store) => ({
...
})),
withMethods((store, postsService = inject(PostsService)) => ({
...
loadPosts: rxMethod<void>(
pipe(
switchMap(() => {
return postsService.getPosts().pipe(
tap((posts) => {
patchState(store, { posts });
})
);
})
)
),
})),
withHooks({
onInit(store) {
store.loadPosts();
},
})
);
We injected PostsService
in our withMethods
and we created a function loadPosts
which returns an RxJS stream. This code is extremely similar to effects in NgRx. We call a service and set posts inside our store with patchState
.
Additionally inside withHooks
we used onInit
hook where we call loadPosts
. Now we don't even need to do anything on initialize in the component. It will be automatically fetched when we inject store in our component.
As you can see we load data from the API and they are rendered automatically from the store.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done