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 effectinternally 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:

https://medium.com/media/b3ec4dd7c62e168e4965c9a8c45f39cd/href

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.

App Conference Organizer listing talks (with polling feature)

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.

TalkServer after applying TalkStoreTalksComponent after applying TalkStore

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 valueChangesfrom 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 objectswhen your Signals require some logic and derived values, andyou 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

GitHub – rainerhahnekamp/conference-organizer: Base demo application for ng-champion related articles

Further Reading

Rainer Hahnekamp- NgRx Signal Store: Why, When and How?

https://medium.com/media/ba6b3c5af5b8ffc15f4c9570a8f5cebc/href

Marko Stanimirović — NgRx SignalStore: In-Depth Look at Signal-Based State Management in Angular by

https://medium.com/media/03a04d4b16cf64d3e37fc25a8c02bd9c/href

Manfred Steyer — The new NgRx Signal Store for Angular, 3+1 Flavors

The new NGRX Signal Store for Angular: 3+n Flavors – ANGULARarchitects

Angular Plus Show: Marko Staminirovic — NgRx Signals Store

https://medium.com/media/845930ed083176c6fee75435e7994600/href

NgRx Signal Store Documentation

NgRx Docs

NgRx Signal Store: The Missing Piece to Signals was originally published in ngconf on Medium, where people are continuing the conversation by highlighting and responding to this story.

Leave a Comment

Your email address will not be published. Required fields are marked *