NgRx Signal Store: The Missing Piece to Signals
Before the arrival of Signals, state management fell into the responsibility of third-party libraries. Signals opened a new chapter.
A Signal contains a value (or state) and can live outside a component.
Components and services can depend on that state. The Signal notifies them of changes.
The consumers react via an effect
or computed
. The first one is for side effects, and the second is for derived values. signal
, computed
, and effect
internally create a non-cyclic dependency graph, where the origins are Signals: A “Single Source of Truth”.
Undeniably, Signals contains many features we see in state management libraries.
Signals are easy to use, but sometimes we need more:
- A Signal might contain a large, nested object, where consumers only require some parts. We have to create these slices manually.
- We want to have code dealing with derived values or logic as close to the Signals as possible.
- Occasionally, we need to pair Signals with RxJs, especially when we deal with asynchronous streams we need to manage.
State management libraries support us in all these use cases. Should we now use Signals for easy tasks and a state management library for harder ones?
No! The new NgRx Signal Store builds upon Signals. It enhances them so that we can also use them for the more challenging parts of our applications.
If you’re more of a visual learner, check out this video:
Signal Store in a Nutshell
Roughly speaking, the Signal Store contains two parts.
The signalState
is for starters. It is a Signal that exposes its properties as nested Signals (aka slices). Its second feature is patching: We don’t need to clone the value like in the update
method or provide the full value with the set
method.
The main part is the function signalStore
. It has the same features as the signalState
but is way more powerful. We will focus on the Signal Store for the rest of this article.
Demo App
We will apply the Signal Store to a demo app called Conference Organizer. It shows a list of talks for a conference.
The list component supports optional polling. It regularly checks changes in the database and updates the state.
The list also shows when the timestamp of the last request and the last actual data change.
The relevant parts are the component and the service that communicates with the backend.
TalkService
export interface Talk {
// various properties
}
export interface TalkData {
talks: Talk[];
meta: {
lastUpdated: Date;
lastEditor: string;
lastRefreshed: Date;
};
}
export interface TalkState extends TalkData {
isPolling: boolean;
}
export const initialValue: TalkState = {
isPolling: false,
talks: [],
meta: {
lastUpdated: new Date(),
lastEditor: '',
lastRefreshed: new Date(),
},
};
export class TalkService {
#talkData = signal(initialValue);
#httpClient = inject(HttpClient);
get talkData() {
return this.#talkData.asReadonly();
}
#findAll(): Observable<TalkData> {
return this.#httpClient.get<TalkData>('/talks');
}
load() {
this.#findAll().subscribe((talkData) => {
const { lastUpdated } = this.#talkData().meta;
if (lastUpdated !== talkData.meta.lastUpdated) {
this.#talkData.update((value) => ({ ...value, ...talkData }));
} else {
this.#talkData.update((value) => ({
...value,
meta: { ...value.meta, lastRefreshed: new Date() },
}));
}
});
}
talks = computed(() => this.#talkData().talks);
dataSource = computed(() =>
this.talks().map((talk) => ({
id: talk.id,
title: talk.title,
speakers: talk.speakers,
schedule: toPrettySchedule(talk),
room: talk.room,
})),
);
pollingSub: Subscription | undefined;
togglePolling(intervalInSeconds = 30) {
if (this.#talkData().isPolling) {
this.pollingSub?.unsubscribe();
this.#talkData.update((value) => ({ ...value, isPolling: false }));
} else {
this.pollingSub = interval(intervalInSeconds * 1000)
.pipe(startWith(true))
.subscribe(() => this.load());
this.#talkData.update((value) => ({ ...value, isPolling: true }));
}
}
find(id: number): Observable<Talk | undefined> {
return of(talks.find((talk) => talk.id === id)).pipe(delay(0));
}
}
TalkService
packs talks
, meta
, and isPolling
information into the Signal #talkData
, and exposes it as a read-only Signal.
The load
method subscribes to an HTTP response and updates the talks.
dataSource
is a computed Signal that depends on talks
. It computes the view model for TalkComponent
. The additional talks
Signal is necessary because dataSource
should only update when talks
change. It should skip changes in meta
or isPolling
.
togglePolling
starts the synchronization. It runs every half a minute.
TalksComponent
@Component({
selector: 'app-talks',
templateUrl: './talks.component.html',
standalone: true,
imports: [MatTableModule, MatButtonModule, RouterLink, UpdateInfoComponent],
})
export class TalksComponent {
talkService = inject(TalkService);
talkData = this.talkService.talkData;
meta = computed(() => this.talkData().meta);
isPolling = computed(() => this.talkData().isPolling);
dataSource = computed(
() => new MatTableDataSource<ViewModel>(this.talkService.dataSource()),
);
constructor() {
this.talkService.load();
}
displayedColumns = ['title', 'speakers', 'room', 'schedule', 'actions'];
togglePolling() {
this.talkService.togglePolling();
}
}
<h2>Talks</h2>
<app-update-info [updateData]="meta()" />
@switch (isPolling()) {
@case (false) {
<button mat-raised-button (click)="togglePolling()">Start Polling</button>
} @case (true) {
<button mat-raised-button (click)="togglePolling()">Stop Polling</button>
}
}
<!-- MatTable -->
TalksComponent
gets talkData
from TalkService
and creates two computed Signals: isPolling
and meta
.
The sub-component <app-update-info>
requires meta
. This is because we only want to update that component if meta
changes.
We also do not want to update the Material table just because polling starts or ends. That’s why isPolling
is a Signal on its own.
The third computed Signal is dataSource
, which depends on TalkService:dataSource
. The component maps that value to a data source for the Material table.
Before you continue, make sure you understand the code!
withState()
The Signal Store will replace the TalkService
step by step.
First, the Signal Store only provides the state. We create the file talk-store.ts and add the following code:
export const TalkStore = signalStore(
{ providedIn: 'root' },
withState(initialValue),
);
signalStore
is a function that creates the actual class. That’s why the variable TalkStore
starts with a capital “T.”
We could instantiate the class via const talkStore = new TalkStore()
, but we want to use Angular’s DI.
{providedIn: 'root'}
as the first parameter makes the service globally available.
With those few lines of code, we get immediate improvements.
In TalkService
, we replace #talkData
and talkData
(getter) with the injected talkStore
:
export class TalkService {
talkStore = inject(TalkStore);
// …
}
TalkStore
exposes its state as a Signal. It is of type DeepSignal
. That is not a native Angular Signal but an enhanced one.
The Signal Store doesn’t expose its complete state as a single Signal but partially via slices/properties. In our case, these are talks
, isPolling
, and meta
. All three of them are of type DeepSignal
.
The DeepSignal
comes without the set
and update
methods. Instead, we have a handy alternative, which is the patchState
function. With patchState
, we only need to provide those values we want to change. So we don’t have to clone the complete value like in update
or pass the value as with set
.
In TalkService
, we currently have the following updates:
this.#talkData.update((value) => ({ …value, isPolling: false }));
this.#talkData.update((value) => ({ …value, …talkData }));
Now it is just:
patchState(this.talkStore, { isPolling: false });
patchState(this.talkStore, talkData));
As explained earlier, the Signal store provides its properties as Signals. Whereas with #talkState
, we have:
this.#talkState().isPolling; // boolean
talkStore
provides isPolling
already as a Signal:
this.talkStore.isPolling() // boolean
As a result, we don’t need to create a separate Signal for talks
. The computed dataSource
uses talks
directly from talkStore
:
dataSource = computed(() =>
this.talkStore.talks().map((talk) => ({
// ...
})),
);
The DeepSignal
becomes handy for TalksComponent
, where we had to construct those slices manually. The new version comes with less code:
export class TalksComponent {
talkService = inject(TalkService);
talkStore = inject(TalkStore);
meta = computed(() => this.talkStore.meta());
isPolling = computed(() => this.talkStore.isPolling());
dataSource = computed(
() => new MatTableDataSource<ViewModel>(this.talkService.dataSource()),
);
// …
}
The code’s readability already improved just by using withState
.
For your convenience, here are the git diffs for TalkService
and TalksComponent
.
withComputed()
withComputed
adds computed Signals to the Store. It contains a function as a parameter, which returns an object literal representing those elements.
We have just one computed Signal with dataSource
. Let’s move the code from TalkService
to TalkStore
:
export const TalkStore = signalStore(
{ providedIn: 'root' },
withState(initialValue),
withComputed((store) => {
return {
dataSource: computed(() =>
store.talks().map((talk) => ({
// mapping code
})),
),
};
}),
);
The function in withComputed
has direct access to the store
and its state slices.
Since withComputed
returns an object literal, we could add as many computed Signals as we want. The new Signals will show up as properties of the store’s instance.
export class TalksComponent {
// …
dataSource = computed(
() => new MatTableDataSource<ViewModel>(this.talkStore.dataSource()),
);
}
Please note the order of how we call withState
and withComputed
matters. With every with*
function (or feature) we add, the store gets more and more properties, and the feature has access to all the properties defined before.
We could add multiple withState
or withComputed
:
const TalkStore = signalStore(
{ providedIn: 'root' },
withState(initialValue),
withState({ conferenceName: 'ng-conf' }),
withComputed((store) => {
return {
dataSource: computed(() =>
store.talks().map((talk) => ({
//mapping code
})),
),
};
}),
);
store
in withComputed
also has the merged state with conferenceName
in it.
Why do we require such a feature? Shouldn’t a single withState
be enough?
For simple cases, the answer is yes.
For a larger code base, we could implement generic features that we can plug into any other signalStore
function. Those generic features define an additional state, methods, or computed.
Those extension features are out of scope, but you can find more in-depth content at the end.
withMethods()
withMethods
is very similar to withComputed
and is the last of the three features you usually require in every Signal Store.
As the name says, it adds methods to the resulting Store. Those methods can also be asynchronous.
withMethods
runs in the injection context, which allows us to use the inject function to get an instance of the HttpClient
.
We migrate the methods togglePolling
and load
. Since load
depends on findAll
, we also have to migrate that one.
withMethods((store) => {
const httpClient = inject(HttpClient);
const findAll = () => httpClient.get<TalkData>('/talks');
let pollingSub: Subscription | undefined;
return {
load() {
findAll().subscribe((talkData) => {
const lastUpdated = store.meta.lastUpdated();
if (lastUpdated !== talkData.meta.lastUpdated) {
patchState(store, talkData);
} else {
patchState(store, (value) => ({
meta: { ...value.meta, lastRefreshed: new Date() },
}));
}
});
},
togglePolling(intervalInSeconds = 30) {
if (store.isPolling()) {
pollingSub?.unsubscribe();
patchState(store, { isPolling: false });
} else {
pollingSub = interval(intervalInSeconds * 1000)
.pipe(startWith(true))
.subscribe(() => this.load());
patchState(store, { isPolling: true });
}
},
find(id: number): Observable<Talk | undefined> {
return of(talks.find((talk) => talk.id === id)).pipe(delay(0));
},
};
})
Before returning the object literal, we request the instance of HttpClient
. pollingSub
and findAll
are hidden methods and only in the scope of withMethods
.
rxMethod
rxMethod
is not a feature function but a function which integrates RxJs into the Signal Store:
const incrementer = rxMethod<number(pipe(
tap(value => console.log(value + 1))
));
incrementer
is a now function with a parameter of type number
that prints out the passed number plus one.
So something like this:
const incrementer = (value: number) => console.log(value + 1);
So what is the benefit of rxMethod
, except obfuscating our code 😉?
incrementer
is an overloaded function. It doesn’t just accept number
as parameter but also Observable<number>
or Signal<number>
.
Internally, we get an Observable, which emits on every call of incrementer
. That gives us the option to add pipe operators.
If we want to print out the value when there was no call within the last second, and we want to send the value to endpoints afterward, with rxMethod
, it is as easy as this:
const incrementAndPersist = rxMethod<number>(pipe(
debounceTime(1000),
map(value => value + 1),
tap(value => console.log(value)),
concatMap(value => this.httpClient.send(url, {value}))
));
incrementAndPersist(1);
incrementAndPersist(2);
incrementAndPersist(3);
It becomes even more powerful, when we pass a Signal
or Observable
to it! For example, that could be valueChanges
from a FormGroup
or FormControl
.
const formControl = new FormControl(1);
const incrementAndPersist = rxMethod<number>(pipe(
debounceTime(1000),
map(value => value + 1),
tap(value => console.log(value)),
concatMap(value => this.httpClient.send(url, {value}))
));
incrementAndPersist(formControl.valueChanges);
It is the homework 👨🎓 of the reader to integrate rxMethod
into our TalkStore
.
Summary
Signals will reshape the codebase of our future Angular applications. The Signal Store helps you manage Signals that
- contain nested or larger objects
- when your Signals require some logic and derived values, and
- you want to keep all of that in a single place.
It does that via patchState
, which is more readable than cloning the value. DeepSignal
adds nested Signals for fine-grained slices.
We have a Builder-like pattern to generate a class of Signal Store. The main functions are withState
, withMethods
, and withComputed
.
The Signal Store can act as a local or global service, thus bringing the best of both worlds: NgRx Global & Component Store.
This article just scratches the surface. There is way more to discover. Below is a list of useful links to get deeper into the Signal Store.
The repository (with solution) is available at
Further Reading
Rainer Hahnekamp- NgRx Signal Store: Why, When and How?
Marko Stanimirović — NgRx SignalStore: In-Depth Look at Signal-Based State Management in Angular by
Manfred Steyer — The new NgRx Signal Store for Angular, 3+1 Flavors
Angular Plus Show: Marko Staminirovic — NgRx Signals Store
NgRx Signal Store Documentation