NgRx SignalStore (the @ngrx/signals package) is a declarative way to build stores on top of signals. You compose features (withState, withComputed, withMethods, withHooks, withEntities…), and the result is an injectable class. Its state is exposed as signals and can be changed only through the methods you define.
It is the next step after the hand-made store on the State management page: the same ideas, but with a standard structure, ready-made entities, RxJS integration for asynchronous flows and reusable pieces across stores. The version used here is @ngrx/signals 22, compatible with Angular 22.
Anatomy of a store
A signalStore(...) is a list of features applied in order. Each feature sees what the ones before it added. withState defines the state: each property becomes a signal (store.todos()), and nested objects become deep signals. withComputed adds derived state, withMethods adds the operations. Only methods may call patchState. The state is protected by default, so you cannot modify it directly from outside the store.
import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
type Filter = 'all' | 'open' | 'done';
interface Todo { id: number; title: string; done: boolean }
export const TodoStore = signalStore(
{ providedIn: 'root' }, // without the option: put it in `providers` where you need it
withState({ todos: [] as Todo[], filter: 'all' as Filter }),
withComputed(({ todos, filter }) => ({
visible: computed(() =>
filter() === 'all' ? todos() : todos().filter((t) => t.done === (filter() === 'done')),
),
openCount: computed(() => todos().filter((t) => !t.done).length),
})),
withMethods((store) => ({
add(title: string): void {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
toggle(id: number): void {
patchState(store, (s) => ({ todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
},
setFilter(filter: Filter): void {
patchState(store, { filter });
},
})),
);patchState receives either a partial object ({ filter }) or a function (state) => Partial<State>. It also accepts as many updaters as you like, applied together. The state is immutable: you always return new objects and arrays, otherwise the signals do not notice the change (the same rule as for signal()).
Where the store lives
Like any service, the lifetime of a store is given by the injector where you provide it. With { providedIn: 'root' } you get a single global instance, good for the user session or a shopping cart. Without the option, you put it in the providers of a component or a route and get a new instance, destroyed together with it. This is the right choice for the state of a page.
// 1. Global: a single instance for the whole application
export const SessionStore = signalStore({ providedIn: 'root' }, withState({ user: null as User | null }));
// 2. Per page / per component: a new instance, destroyed together with the component
export const ProductsStore = signalStore(withEntities<Product>() /* ... */);
@Component({
providers: [ProductsStore], // each <app-catalog> has its own store
template: `...`,
})
export class Catalog {
readonly store = inject(ProductsStore);
}
// 3. Per route (feature): lives as long as the route does
{ path: 'catalog', providers: [ProductsStore], loadComponent: () => import('./catalog') }Entities
withEntities<Product>() adds a normalised collection: in the state you have ids and entityMap, and as a signal you have entities(), a derived array. The updaters from @ngrx/signals/entities (setAllEntities, addEntity, updateEntity, upsertEntity, removeEntity…) are passed directly to patchState. For several collections in the same store there are named collections (withEntities({ entity: type<Product>(), collection: 'product' }) → productEntities()).
Asynchronous flows with rxMethod
rxMethod (from @ngrx/signals/rxjs-interop) turns an RxJS pipe into a method. You can call it with a value, with an Observable or with a signal. With a signal, the method runs on every change of it. The call store.search(store.query) in onInit links the search to the query only once. The rest is ordinary RxJS: debounceTime, switchMap to cancel the outdated request, catchError inside the switchMap, so that an error does not stop the stream (see Advanced RxJS). The subscription closes by itself when the store is destroyed.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, EMPTY, pipe, switchMap, tap } from 'rxjs';
export const ProductsStore = signalStore(
withEntities<Product>(), // state: ids + entityMap; signal: entities()
withState({ query: '' }),
withRequestStatus(), // custom feature (below)
withMethods((store, http = inject(HttpClient)) => ({
setQuery: (query: string) => patchState(store, { query }),
buy: (id: number) =>
patchState(store, updateEntity({ id, changes: (p) => ({ stock: p.stock - 1 }) })),
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, setPending())),
switchMap((q) =>
http.get<Page<Product>>(`/api/products?q=${encodeURIComponent(q)}`).pipe(
tap((page) => patchState(store, setAllEntities(page.items), setFulfilled())),
catchError(() => {
patchState(store, setError('The server is not responding.'));
return EMPTY; // the outer stream stays alive
}),
),
),
),
),
})),
withHooks({
onInit(store) {
store.search(store.query); // rxMethod receives a SIGNAL: it runs on every change
},
}),
);tapResponse instead of tap + catchError. It does the same thing more compactly, but comes from the separate @ngrx/operators package. The demo here uses only RxJS.Reusable features
signalStoreFeature(...) groups several features into a function that you add to any store, for example withRequestStatus(): the request state plus isPending and error. The updaters are simple functions that return a piece of state, so you combine them with others in the same patchState. This removes the copy-pasted loading/error code from every store.
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
// A reusable piece of store: state + computeds, composed into any signalStore
export function withRequestStatus() {
return signalStoreFeature(
withState<{ requestStatus: RequestStatus }>({ requestStatus: 'idle' }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
error: computed(() => {
const s = requestStatus();
return typeof s === 'object' ? s.error : null;
}),
})),
);
}
// Updaters: simple functions that return a piece of state → patchState(store, setPending())
export const setPending = () => ({ requestStatus: 'pending' as RequestStatus });
export const setFulfilled = () => ({ requestStatus: 'fulfilled' as RequestStatus });
export const setError = (error: string) => ({ requestStatus: { error } as RequestStatus });- No products.
{
"query": "",
"inStockOnly": false,
"cart": 0,
"requestStatus": "idle",
"ids": []
}The rest of the toolbox
signalState is the “no store” option: an object of signals for the local state of a component, still with patchState. withProps adds properties that are not state (services, resources, observables). withLinkedState does for a store what linkedSignal does for a component. getState gives you a snapshot of the state, and watchState notifies you synchronously on every change. For tests, unprotected(store) from @ngrx/signals/testing lets you put the store in a given state. The package also has an events plugin (@ngrx/signals/events), for the Redux-style approach with events and reducers.
import { patchState, signalState } from '@ngrx/signals';
// For the local state of ONE component: no store, just an object of signals
@Component({ /* ... */ })
export class Filters {
readonly state = signalState({ query: '', page: 1, sort: 'name' as 'name' | 'price' });
next(): void {
patchState(this.state, (s) => ({ page: s.page + 1 }));
}
// in the template: state.query(), state.page()
}When YES, when NO
- Feature state used by several components, with asynchronous operations (search, saving, pagination).
- Entity collections that are updated often (editable lists, carts, tables).
- Several teams that want the same shape for all stores, and reusable features.
- You have classic NgRx Store and want something lighter for new features. They can coexist.
- The state of a single component:
signal()orsignalState. - Data read from the server and only displayed:
httpResource(see resource). - A small service with two signals and three methods: a hand-made store is clearer for everyone.
- Applications where nobody on the team knows NgRx and the deadline is tomorrow. The dependency has a learning cost.
| Hand-made store | SignalStore | NgRx Store (Redux) | |
|---|---|---|---|
| Boilerplate | minimal | small | large (actions, reducers, effects, selectors) |
| Standard structure | whatever the team decides | yes | yes, very strict |
| Entities | you write them yourself | withEntities | @ngrx/entity |
| Async | resource / RxJS | rxMethod, resource | effects |
| Good for | small features | most applications | complex flows, event auditing |
Common pitfalls
- Mutating the state (
s.todos.push(x)in an updater). The signal receives the same reference and nothing updates. Always return new arrays and objects. providedIn: 'root'for the state of a page. The state “sticks” from one visit to the next and gets mixed between component instances. Put the store in the page’sproviders.catchErrorplaced afterswitchMapin anrxMethod. The first error closes the stream and the search stops working. Put it in the inner stream.- Business logic in components, with the store used only as a “bag of data”. The store’s methods are the place for it.
- One huge store for the whole application. Make small stores, per feature, and compose them with features. That is easier to test and to load lazily.
effect()in components to synchronise stores with each other. PreferwithComputedorwithLinkedState(see effect()).
Cheat sheet
signalStore(config?, ...features) config: { providedIn: 'root' } or nothing (→ providers: [Store])
withState({ ... }) state → signals: store.x()
withComputed(store => ({ y: computed(...) }))
withMethods((store, dep = inject(X)) => ({ ... })) the only place where you modify the state
withHooks({ onInit(store) {}, onDestroy(store) {} })
withProps / withLinkedState non-state properties / linked state (like linkedSignal)
signalStoreFeature(...features) reusable pieces: withRequestStatus(), withPagination()
patchState(store, partial | updater, ...) several updaters in a single call
getState(store) / watchState(store, fn) snapshot / synchronous observer of the state
rxMethod<T>(pipe(...)) from @ngrx/signals/rxjs-interop — accepts a value, a signal or an Observable
withEntities<T>() + setAllEntities, addEntity, updateEntity, removeEntity, upsertEntity (@ngrx/signals/entities)
signalState({ ... }) local state in a component, without a store
unprotected(store) from @ngrx/signals/testing — set the state directly in tests