Since the advent of Signals, the NGRX team has been working on a store that leverages this new reactive building block. This new NGRX Signal Store was released with NGRX 17, is fully Signal-based, and highly extensible.
This article shows how to incorporate it in your application. For this, it shows up 3+1 different flavors of using it.
Getting the Package
To install the Signal Store, you just need to add the package @ngrx/signals
to your application:
npm i @ngrx/signals
Flavor 1: Lightweight with signalState
🔀 Branch: arc-signal-store
A very lightweight way of managing Signals with the Signal Store is its signalState
function (not to be confused with the signalStore
function). It creates a simple container for managing the passed state using Signals. This container is represented by the type SignalState
:
@Injectable({ providedIn: 'root' })
import { signalState } from '@ngrx/signals';
[...]
export class FlightBookingFacade {
[...]
private state = signalState({
from: 'Paris',
to: 'London',
preferences: {
directConnection: false,
maxPrice: 350,
},
flights: [] as Flight[],
basket: {} as Record<number, boolean>,
});
// fetch read-only signals
flights = this.state.flights;
from = this.state.from;
to = this.state.to;
basket = this.state.basket;
[...]
}
Each top-level state property gets its own Signal. These properties are retrieved as read-only Signals, ensuring a separation between reading and writing: Consumers using the Signals can just read their values. For updating the state, the service encapsulating the state provides methods (see below). This ensures that the state can only be updated in a well-defined manner.
Also, nested objects like the one provided by the preferences
property above result in nested signals. Hence, one can retrieve the whole preferences
object as a Signal but also its properties:
const ps = this.state.preferences();
const direct = this.state.preferences.directConnection();
Currently, this isn't implemented for arrays, as Angular's envisioned Signal Components will solve this use case by creating a Signal for each iterated item.
Selecting and Computing Signals
As the Signal Store provides the state as Signals, we can directly use Angular's computed
function:
selected = computed(() =>
this.flights().filter((f) => this.basket()[f.id])
);
Here, computed
serves the same purpose as Selectors in the Redux-based NGRX Store: It
enables us to calculate different state representations for different use cases. These so-called View Models are only recomputed when at least one of the underlying signals changes.
Updating State
For updating the SignalState
, Signal Store provides us with a patchState
function:
import { patchState } from '@ngrx/signals';
[...]
updateCriteria(from: string, to: string): void {
patchState(this.state, { from, to })
}
Here, we pass in the state container and a partial state. As an alternative, one can pass a function taking the current state and transforming it to the new state:
updateBasket(id: number, selected: boolean): void {
patchState(this.state, state => ({
basket: {
...state.basket,
[id]: selected,
},
}));
}
Side Effects
Besides updating the state, methods can also trigger side effects like loading and saving objects:
async load() {
if (!this.from() || !this.to()) return;
const flights = await this.flightService.findPromise(
this.from(),
this.to()
);
patchState(this.state, { flights });
}
Decoupling Intention from Execution
Sometimes, the caller of patchState
only knows that some state needs to be updated without knowing where it's located. For such cases, you can provide Updaters. Updaters are just functions taking a current state and returning an updated version of it:
type BasketSlice = { basket: Record<number, boolean> };
type BasketUpdateter = (state: BasketSlice) => BasketSlice;
export function updateBasket(flightId: number, selected: boolean): BasketUpdateter {
return (state) => ({
...state,
basket: {
...state.basket,
[flightId]: selected,
},
});
}
It's also fine to just return a partial state. It will be patched over the current state:
type BasketSlice = { basket: Record<number, boolean> };
type BasketUpdateter = (state: BasketSlice) => BasketSlice;
export function updateBasket(flightId: number, selected: boolean): BasketUpdateter {
return (state) => ({
basket: {
...state.basket,
[flightId]: selected,
},
});
}
If you don't need to project the current state, just returning a partial state is fine too. In this case, you can skip the inner function:
export function updateFlights(flights: Flight[]) {
return { flights };
}
Updater can be defined in the Store
's (signalState
's) "sovereign territory". For the consumer, it is just a black box:
patchState(updateBasket(id, selected))
Passing an Updater to patchState
expresses an intention. This is similar to dispatching an Action in the classic NGRX store. However, other than with Redux, no eventing is involved, and we cannot prevent the caller from directly passing their own Updaters. For the latter reason, I'm hiding the SignalStore
behind a facade.
More: Angular Architecture Workshop (online, interactive, advanced)
Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!
All Details (English Workshop) | All Details (German Workshop)
Flavor 2: Powerful with signalStore
🔀 Branch: arc-signal-store-2
Similar to signalState
, the signalStore
function creates a container managing state with Signals. However, now, this container is a fully-fledged Store that not only comes with state Signals but also with computed Signals as well as methods for updating the state and for triggering side effects. Hence, there is less need for crafting a facade by hand, as shown above.
Technically, the Store is an Angular service that is composed of several pre-existing features:
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withState({
from: 'Paris',
to: 'London',
initialized: false,
flights: [] as Flight[],
basket: {} as Record<number, boolean>,
}),
// Activating further features
withComputed([...]),
withMethods([...]),
withHooks([...]),
)
In this case, the service is also registered in the root scope. When skipping { providedIn: 'root' }
, one needs to register the service by hand, e. g., by providing it when bootstrapping the application, within a router configuration, or on component level.
Selecting and Computing Signals
The withComputed
feature takes the store with its state Signals and defines an object with calculated signals:
withComputed((store) => ({
selected: computed(() => store.flights().filter((f) => store.basket()[f.id])),
criteria: computed(() => ({ from: store.from(), to: store.to() })),
})),
The returned computed signals become part of the store. A more compact version might involve directly destructuring the passed store:
withComputed(({ flights, basket, from, to }) => ({
selected: selectSignal(() => flights().filter((f) => basket()[f.id])),
criteria: selectSignal(() => ({ from: from(), to: to() })),
})),
Methods for Updating State and Side Effects
Similar to withComputed
, withMethods
also takes the store and returns an object with methods:
withMethods((state) => {
const { basket, flights, from, to, initialized } = state;
const flightService = inject(FlightService);
return {
updateCriteria: (from: string, to: string) => {
patchState(state, { from, to });
},
updateBasket: (flightId: number, selected: boolean) => {
patchState(state, {
basket: {
...basket(),
[flightId]: selected,
},
});
},
delay: () => {
const currentFlights = flights();
const flight = currentFlights[0];
const date = addMinutes(flight.date, 15);
const updFlight = { ...flight, date };
const updFlights = [updFlight, ...currentFlights.slice(1)];
patchState(state, { flights: updFlights });
},
load: async () => {
if (!from() || !to()) return;
const flights = await flightService.findPromise(from(), to());
patchState(state, { flights });
}
};
}),
withMethods
runs in an injection context and hence can use inject
to get hold of services. After withMethods
was executed, the retrieved methods are added to the store.
Consuming the Store
From the caller's perspective, the store looks a lot like the facade shown above. We can inject it into a consuming component:
@Component([...])
export class FlightSearchComponent {
private store = inject(FlightBookingStore);
from = this.store.from;
to = this.store.to;
basket = this.store.basket;
flights = this.store.flights;
selected = this.store.selected;
async search() {
this.store.load();
}
delay(): void {
this.store.delay();
}
updateCriteria(from: string, to: string): void {
this.store.updateCriteria(from, to);
}
updateBasket(id: number, selected: boolean): void {
this.store.updateBasket(id, selected);
}
}
Hooks
The function withHooks
provides another feature allowing to setup lifecycle hooks to run when the store is initialized or destroyed:
withHooks({
onInit({ load }) {
load()
},
onDestroy({ flights }) {
console.log('flights are destroyed now', flights());
},
}),
Both hooks get the store passed. One more time, by using destructuring, you can focus on a subset of the stores members.
rxMethod
🔀 Branch: arc-signal-store-rx
While Signals are easy to use, they are not a full replacement for RxJS. For leveraging RxJS and its powerful operators, the Signal Store provides a secondary entry point @ngrx/signals/rxjs-interop
, containing a function rxMethod<T>
. It allows working with an Observable representing side-effects that automatically run when specific values change:
import { rxMethod } from '@ngrx/signals/rxjs-interop';
[...]
withMethods(({ $update, basket, flights, from, to, initialized }) => {
const flightService = inject(FlightService);
return {
[...]
connectCriteria: rxMethod<Criteria>((c$) => c$.pipe(
filter(c => c.from.length >= 3 && c.to.length >= 3),
debounceTime(300),
switchMap((c) => flightService.find(c.from, c.to)),
tap(flights => patchState(state, { flights }))
))
}
});
The type parameter T
defines the type the rxMethod
works on. While the rxMethod
receives an Obserable<T>
, the caller can also pass an Observable<T>
, a Signal<T>
, or T
directly. In the latter two cases, the passed values are converted into an Observable.
After defining the rxMethod
, somewhere else in the application, e. g. in a hook or a regular method, you can call this effect:
withHooks({
onInit({ loadBy, criteria }) {
connectCriteria(criteria);
},
})
Here, the criteria Signal -- a computed signal -- is passed. Every time this Signal changes, the effect within connectCriteria
is re-executed.
Custom Features - The Road Towards Further Flavors
🔀 Branch: arc-signal-store-custom
Besides configuring the Store with baked-in features, everyone can write their own features to automate repeating tasks. The playground provided by Marko Stanimirović, the NGRX contributor behind the Signal Store, contains several examples of such features.
One of the examples found in this repository is a CallState feature defining a state property informing about the state of the current HTTP call:
export type CallState = 'init' | 'loading' | 'loaded' | { error: string };
In this section, I'm using this example to explain how to provide custom features.
Defining Custom Features
A feature is usually created by calling signalStoreFeature
. This function constructs a new feature on top of existing ones.
// Taken from: https://github.com/markostanimirovic/ngrx-signal-store-playground/blob/main/src/app/shared/call-state.feature.ts
import { computed } from '@angular/core';
import {
signalStoreFeature,
withComputed,
withState,
} from '@ngrx/signals';
export type CallState = 'init' | 'loading' | 'loaded' | { error: string };
export function withCallState() {
return signalStoreFeature(
withState<{ callState: CallState }>({ callState: 'init' }),
withComputed(({ callState }) => ({
loading: computed(() => callState() === 'loading'),
loaded: computed(() => callState() === 'loaded'),
error: computed(() => {
const state = callState();
return typeof state === 'object' ? state.error : null
}),
}))
);
}
For the state properties added by the feature, one can provide Updaters
:
export function setLoading(): { callState: CallState } {
return { callState: 'loading' };
}
export function setLoaded(): { callState: CallState } {
return { callState: 'loaded' };
}
export function setError(error: string): { callState: CallState } {
return { callState: { error } };
}
Updaters allows the consumer to modify the feature state without actually knowing how it's structured.
Using Custom Features
For using Custom Features, just call the provided factory when setting up the store:
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withState({ [...] }),
// Add feature:
withCallState(),
[...]
withMethods([...])
[...]
);
The provided properties, methods, and Updaters can be used in the Store's methods:
load: async () => {
if (!from() || !to()) return;
// Setting the callState via an Updater
patchState(state, setLoading());
const flights = await flightService.findPromise(from(), to());
patchState(state, { flights });
// Setting the callState via an Updater
patchState(state, setLoaded());
},
The consumer of the store sees the properties provided by the feature too:
private store = inject(FlightBookingStore);
flights = this.store.flightEntities;
loading = this.store.loading;
As each feature is transforming the Store's properties and methods, make sure to call them in the right order. If we assume that methods registered with withMethods
use the CallState
, withCallState
has to be called before withMethods
.
Flavor 3: Built-in Features like Entity Management
🔀 Branch: arc-signal-store-entities
The NGRX Signal Store already comes with a convenient extension for managing entities. It can be found in the secondary entry point @ngrx/signals/entities
and provides data structures for entities but also several Updaters, e. g., for inserting entities or for updating a single entity by id.
To setup entity management, just call the withEntities
function:
import { withEntities } from '@ngrx/signals/entities';
const BooksStore = signalStore(
[...]
// Defining an Entity
withEntities({ entity: type<Flight>(), collection: 'flight' }),
// withEntities created a flightEntities signal for us:
withComputed(({ flightEntities, basket, from, to }) => ({
selected: computed(() => flightEntities().filter((f) => basket()[f.id])),
criteria: computed(() => ({ from: from(), to: to() })),
})),
withMethods((state) => {
const { basket, flightEntities, from, to, initialized } = state;
const flightService = inject(FlightService);
return {
[...],
load: async () => {
if (!from() || !to()) return;
patchState(state, setLoading());
const flights = await flightService.findPromise(from(), to());
// Updating entities with out-of-the-box setAllEntities Updater
patchState(state, setAllEntities(flights, { collection: 'flight' }));
patchState(state, setLoaded());
},
[...],
};
}),
);
The passed collection name prevents naming conflicts. In our case, the collection is called flight
, and hence the feature creates several properties beginning with flight
, e.g., flightEntities
.
There is quite an amount of ready-to-use Updaters:
addEntity
addEntities
removeEntity
removeEntities
removeAllEntities
setEntity
setEntities
setAllEntities
updateEntity
updateEntities
updateAllEntities
Similar to @ngrx/entities
, internally, the entities are stored in a normalized way. That means they are stored in a dictionary, mapping their primary keys to the entity objects. This makes it easier to join them together to View Models needed for specific use cases.
As we call our collection flight
, withEntities
creates a Signal flightEntityMap
mapping flight ids to our flight objects. Also, it creates a Signal flightIds
containing all the ids in the order. Both are used by the also added computed signal flightEntities
used above. It returns all the flights as an array respecting the order of the ids within flightIds
. Hence, if you want to rearrange the positions of our flights, just update the flightIds
property accordingly.
For building the structures like the flightEntityMap
, the Updaters need to know how the entity's id is called. By default, it assumes a property id
. If the id is called differently, you can tell the Updater by using the idKey
property:
patchState(state, setAllEntities(flights, { collection: 'flight', idKey: 'flightId' }));
The passed property needs to be a string
or number
. If it's of a different data type or if it doesn't exist at all, you get a compilation error.
Conclusion
The upcoming NGRX Signal Store allows managing state using Signals. The most lightweight option for using this library is just to go with a SignalState
container. This data structure provides a Signal for each state property. These signals are read-only. For updating the state, you can use the patchState
function. To make sure updates only happen in a well-defined way, the signalState
can be hidden behind a facade.
The SignalStore
is more powerful and allows to register optional features. They define the state to manage but also methods operating on it. A SignalStore
can be provided as a service and injected into its consumers.
The SignalStore
also provides an extension mechanism for implementing custom features to ease repeating tasks. Out of the box, the Signal Store comes with a pretty handy feature for managing entities.
What's next? More on Architecture!
Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):
- According to which criteria can we subdivide a huge application into sub-domains?
- How can we make sure, the solution is maintainable for years or even decades?
- Which options from Micro Frontends are provided by Module Federation?
Feel free to download it here now!