Installing the Signal Store
Adding the Signal Store to your project is straightforward — simply install the @ngrx/signals package:
npm i @ngrx/signals
Flavor 1: Keeping It Light with signalState
🔀 Branch: arc-signal-store
For those who want a minimal approach to Signal-based state management, the Signal Store offers the signalState function. Despite the similar name, this differs from the signalStore function. It produces a lightweight wrapper around your state, represented by the SignalState type:
@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;
[...]
}
Every top-level property of the state gets its own dedicated Signal. These are exposed as read-only Signals, which enforces a clean separation between reading and writing. Components and other consumers can only read the current value; all updates go through methods provided by the encapsulating service. This approach guarantees that state changes happen in a controlled and predictable way.
Nested structures, such as the preferences object in the example above, are also broken down into nested Signals. You can therefore subscribe to the entire object or drill down to individual properties:
const ps = this.state.preferences();
const direct = this.state.preferences.directConnection();
At present, this automatic nesting does not apply to arrays. The reasoning is that Angular's upcoming Signal Components will handle this scenario by generating a Signal for each item during iteration.
Deriving and Computing Values
Because the state is exposed as Signals, Angular's built-in computed function works directly on top of it:
selected = computed(() =>
this.flights().filter((f) => this.basket()[f.id])
);
In this context, computed plays the same role that Selectors do in the classic Redux-based NGRX Store: it lets you shape different views of the state for different parts of your UI. These so-called View Models are only recalculated when one of the underlying Signals actually changes.
Modifying the State
To update a SignalState, the Signal Store ships with the patchState function:
import { patchState } from '@ngrx/signals';
[...]
updateCriteria(from: string, to: string): void {
patchState(this.state, { from, to })
}
The first argument is the state container, the second is a partial state object. Alternatively, you can provide a function that receives the current state and returns the updated one:
updateBasket(id: number, selected: boolean): void {
patchState(this.state, state => ({
basket: {
...state.basket,
[id]: selected,
},
}));
}
Handling Side Effects
Of course, methods are not limited to updating state. They can also initiate side effects such as fetching or persisting data:
async load() {
if (!this.from() || !this.to()) return;
const flights = await this.flightService.findPromise(
this.from(),
this.to()
);
patchState(this.state, { flights });
}
Separating Intent from Implementation
There are situations where the caller of patchState knows that something needs to change but doesn't care where that change happens. For these cases, you can use Updaters. An Updater is simply a function that takes the current state and produces a new 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,
},
});
}
Returning only a partial state is equally valid — it gets merged over the existing 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 inspect the current state, you can skip the inner function altogether and just pass a partial state:
export function updateFlights(flights: Flight[]) {
return { flights };
}
Updaters can be declared within the "sovereign territory" of the Store (or signalState). From the consumer's perspective, they are a complete black box:
patchState(updateBasket(id, selected))
Passing an Updater into patchState expresses an intention, much like dispatching an Action in NGRX's classic store. The difference is that there is no eventing mechanism involved, and nothing prevents a caller from supplying their own Updater. Because of this, I tend to hide the SignalStore behind a facade for stricter control.
Learn More: Angular Architecture Workshop
Our Angular Architecture workshop helps you build enterprise-scale, maintainable Angular applications.
All Details (English Workshop) | All Details (German Workshop)
Flavor 2: Going Full-Fledged with signalStore
🔀 Branch: arc-signal-store-2
Much like signalState, the signalStore function creates a container that manages state via Signals. However, this container is a complete Store: it ships not only with state Signals but also with computed Signals, update methods, and side-effect triggers out of the box. As a result, there is typically no need to hand-craft a facade as described in the previous section.
Under the hood, the Store is an Angular service assembled from a combination of predefined 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 the example, the service is provided at the root level. If you omit { providedIn: 'root' }, you must register the service manually — for instance, during application bootstrap, in your router configuration, or at the component level.
Deriving Computed Signals
The withComputed feature accepts the store (with its state Signals) and returns an object of calculated Signals:
withComputed((store) => ({
selected: computed(() => store.flights().filter((f) => store.basket()[f.id])),
criteria: computed(() => ({ from: store.from(), to: store.to() })),
})),
These computed Signals become part of the store's public API. For a more compact syntax, you can destructure the passed store directly:
withComputed(({ flights, basket, from, to }) => ({
selected: selectSignal(() => flights().filter((f) => basket()[f.id])),
criteria: selectSignal(() => ({ from: from(), to: to() })),
})),
Adding Methods and Side Effects
In the same vein as withComputed, the withMethods feature takes the store and returns an object containing 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 });
}
};
}),
Since withMethods runs within an injection context, you can freely use inject to access services. Once executed, the returned methods are added to the store.
Using the Store in Your Application
From the caller's point of view, this store looks remarkably similar to the facade we built manually earlier. It can be injected directly into a 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);
}
}
Lifecycle Hooks
The withHooks feature lets you define lifecycle hooks that fire when the store is initialized or destroyed:
withHooks({
onInit({ load }) {
load()
},
onDestroy({ flights }) {
console.log('flights are destroyed now', flights());
},
}),
Both hooks receive the store as their argument. Again, destructuring lets you limit the exposure to only the members you really need.
Embracing RxJS with rxMethod
🔀 Branch: arc-signal-store-rx
Signals are great, but they don't fully replace RxJS. To take advantage of RxJS operators, the Signal Store offers a secondary entry point, @ngrx/signals/rxjs-interop, which exports the rxMethod<T> function. It allows you to define side effects that react to Observables and re-run automatically when 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 specifies the data type the rxMethod operates on. While the method's definition expects an Observable<T>, callers may also pass a plain Observable<T>, a Signal<T>, or even T directly. The latter two are seamlessly converted into an Observable internally.
Once the rxMethod is defined, you can invoke it from anywhere in your application — be it a hook or a regular method:
withHooks({
onInit({ loadBy, criteria }) {
connectCriteria(criteria);
},
})
Here, a computed Signal (the criteria) is passed in. Every time this Signal changes, the effect inside connectCriteria fires again.
Custom Features — Unlocking More Flavors
🔀 Branch: arc-signal-store-custom
Beyond the built-in features, you can extend the Store with your own custom features to automate recurring patterns. The playground from Marko Stanimirović — the NGRX contributor behind the Signal Store — offers several examples.
One such example is the CallState feature, which introduces a state property tracking the status of the current HTTP request:
export type CallState = 'init' | 'loading' | 'loaded' | { error: string };
Let's walk through this example to understand how to build your own custom features.
Creating a Custom Feature
Typically, a feature starts with a call to signalStoreFeature. This function layers a new feature on top of whatever already exists:
// 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 that a feature introduces, you can also 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 } };
}
These Updaters allow consumers to modify the feature's state without needing to understand its internal shape.
Applying Your Custom Feature
Using the custom feature is as simple as invoking its factory when configuring your store:
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withState({ [...] }),
// Add feature:
withCallState(),
[...]
withMethods([...])
[...]
);
The properties, methods, and Updaters it provides become available within the Store's other 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());
},
Consumers of the store see the feature's exposed properties just like any other store member:
private store = inject(FlightBookingStore);
flights = this.store.flightEntities;
loading = this.store.loading;
Because each feature transforms the store's properties and methods, ordering matters. If withMethods relies on the CallState feature, make sure to call withCallState before anything that depends on it.
Flavor 3: Built-in Features like Entity Management
🔀 Branch: arc-signal-store-entities
The NGRX Signal Store offers a built-in extension specifically designed for entity management. This utility lives in the secondary entry point @ngrx/signals/entities and supplies both entity data structures and a range of Updaters — for instance, methods to insert entities or to modify a single entity identified by its key.
Activating entity management is straightforward: just invoke 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());
},
[...],
};
}),
);
Supplying a collection name helps avoid naming collisions. In this example, the collection is named flight, so the feature generates properties prefixed with flight, such as flightEntities.
The set of ready-made Updaters is quite extensive:
addEntityaddEntitiesremoveEntityremoveEntitiesremoveAllEntitiessetEntitysetEntitiessetAllEntitiesupdateEntityupdateEntitiesupdateAllEntities
Just like in @ngrx/entities, the data is held internally in a normalized format. This means entities are kept in a dictionary that maps their primary keys to the corresponding objects. Such a layout simplifies combining them into View Models tailored to particular scenarios.
Since the collection here is called flight, withEntities produces a Signal named flightEntityMap that maps flight ids to flight objects. It also generates a flightIds Signal containing all ids in their current sequence. Both serve as inputs to the additional computed Signal flightEntities used earlier — it outputs all flights as an array, preserving the ordering defined by flightIds. So, to reorder the flights, simply adjust the flightIds property accordingly.
To assemble structures like flightEntityMap, the Updaters must know which property serves as the entity's identifier. By default, they assume an id property exists. If your entities use a different field name, pass it via the selectId option:
patchState(state, setAllEntities(flights, { collection: 'flight', selectId: (f) => f.id }));
The value provided must be either a string or a number. If it is of another type or the referenced property is absent, the compiler will raise an error.
Conclusion
The forthcoming NGRX Signal Store enables state management through Signals. The most minimal approach is to use a SignalState container. This structure exposes a read-only Signal for every property in the state. To modify the state, the patchState function is available. For stricter update patterns, you can wrap the signalState behind a facade.
The SignalStore offers more flexibility, allowing optional features to be registered. These features define both the state shape and the methods that operate on it. A SignalStore can be provided as a service and injected wherever needed.
Moreover, the SignalStore includes an extension point for building custom features that encapsulate repetitive logic. Out of the box, it already ships with a notably useful feature for handling entities.
What's next? More on Architecture!
For deeper insights into enterprise-scale Angular architectures, check out our free eBook (5th edition, 12 chapters):
- How can a large application be split into meaningful sub-domains?
- What steps ensure the solution remains maintainable over years or even decades?
- Which Micro Frontend capabilities does Module Federation offer?
You're welcome to download it here right away!

