NgRx Signal Store
Signal Store keeps the NgRx family but drops the Redux ceremony, replacing actions and reducers with state signals and methods
Modern Angular only No equivalent exists in the original 100 Days series. Written fresh for Angular v22.
Lead with this: NgRx Signal Store replaces Actions + Reducers + Effects with
signals and methods — the same NgRx library family, but without the Redux
ceremony. State is a group of signals. You update it with patchState().
Async work lives in rxMethod(). No action strings, no switch statements.
What it is
NgRx Signal Store (@ngrx/signals) is a separate package from @ngrx/store.
It is not Redux. It does not use actions, reducers, or effects. Instead, it
gives you a composable, signal-native way to build stores using a set of
"feature" functions:
| Feature | What it adds |
|---|---|
withState() | Reactive state — each property becomes a Signal<T> |
withComputed() | Derived signals built from state |
withMethods() | Public methods that update state or trigger async work |
withProps() | Private dependencies — inject services into the store |
withHooks() | Lifecycle callbacks — onInit and onDestroy |
withEntities() | Collection management (from @ngrx/signals/entities) |
You compose a store by calling signalStore(feature1, feature2, feature3, ...).
The result is an injectable Angular service.
NgRx Signal Store vs NgRx Store — when to use which:
| NgRx Signal Store | NgRx Store | |
|---|---|---|
| Boilerplate | Low | High |
| DevTools | Community (@angular-architects/ngrx-toolkit) | Official, excellent |
| Conventions | Flexible | Strict |
| Redux pattern | No | Yes |
| Best for | Feature stores, mid-size apps | Large teams, strict audit trail needed |
How it works under the hood
NgRx Store — explicit Redux ceremony
NgRx Store follows the Redux pattern: every state change goes through an action, a reducer, and produces a new state object. The audit trail is complete but the boilerplate is high — a simple "load users" feature needs actions (3+), a reducer, a feature, selectors, and an effects class.
dispatch(LoadUsers) → Effect intercepts → HTTP → dispatch(LoadUsersSuccess) → Reducer produces newState → Selector projects → Component re-rendersEvery hop is explicit and logged. Powerful for debugging at scale. Verbose for straightforward data fetching.
NgRx Signal Store — signals + composition
Signal Store collapses the Redux hops into direct method calls and signal reads:
component calls store.loadUsers() → method sets { isLoading: true } via patchState() → rxMethod fires HTTP → tapResponse sets { users, isLoading: false } via patchState() → signals notify template → component re-rendersNo action strings. No switch statements. No separate effects class. State
updates happen in one place — the withMethods() block — using patchState(),
which patches the state immutably (like store.dispatch() but synchronous
and direct).
Every property in withState() becomes a signal automatically. When you call
patchState(store, { count: 5 }), Angular's signal graph marks all consumers
of store.count as dirty and schedules a re-render — exactly as if you had
called count.set(5) on a writable signal.
Basic usage
Creating a store
import { computed, inject } from '@angular/core';import { patchState, signalStore, withComputed, withHooks, withMethods, withProps, withState,} from '@ngrx/signals';import { rxMethod } from '@ngrx/signals/rxjs-interop';import { tapResponse } from '@ngrx/operators';import { pipe, switchMap, tap } from 'rxjs';import { UserService } from './user.service';
type UsersState = { users: User[]; isLoading: boolean; error: string | null; query: string;};
const initialState: UsersState = { users: [], isLoading: false, error: null, query: '',};
export const UsersStore = signalStore( { providedIn: 'root' }, // injectable anywhere; omit for component-scoped
withState(initialState), // state.users, state.isLoading become signals
withProps(() => ({ // inject private dependencies here _userService: inject(UserService), })),
withComputed(({ users, query }) => ({ // computed() reads signals — auto-updates when users or query changes filteredUsers: computed(() => { const q = query().toLowerCase(); return users().filter(u => u.name.toLowerCase().includes(q)); }), userCount: computed(() => users().length), })),
withMethods((store) => ({ // Synchronous state update setQuery(query: string): void { patchState(store, { query }); },
// Async method using rxMethod — accepts value, Signal, or Observable loadUsers: rxMethod<void>( pipe( tap(() => patchState(store, { isLoading: true, error: null })), switchMap(() => store._userService.getAll().pipe( tapResponse({ next: users => patchState(store, { users, isLoading: false }), error: (e: Error) => patchState(store, { error: e.message, isLoading: false }), }) ) ) ) ),
deleteUser: rxMethod<string>( pipe( switchMap(id => store._userService.delete(id).pipe( tapResponse({ next: () => patchState(store, state => ({ users: state.users.filter(u => u.id !== id), })), error: (e: Error) => patchState(store, { error: e.message }), }) ) ) ) ), })),
withHooks({ onInit(store): void { // Runs in injection context — safe to call rxMethod or inject() here store.loadUsers(); }, onDestroy(store): void { console.log('Store destroyed. Final user count:', store.userCount()); }, }));Using the store in a component
import { Component, inject } from '@angular/core';import { UsersStore } from './users.store';
@Component({ selector: 'app-users', standalone: true, template: ` <input [value]="store.query()" (input)="store.setQuery($any($event.target).value)" placeholder="Search users…" />
<p>{{ store.filteredUsers().length }} of {{ store.userCount() }} users</p>
@if (store.isLoading()) { <app-spinner /> } @else if (store.error()) { <app-error [message]="store.error()!" /> } @else { @for (user of store.filteredUsers(); track user.id) { <app-user-card [user]="user" (delete)="store.deleteUser(user.id)" /> } } `,})export class UsersComponent { store = inject(UsersStore); // store.users(), store.isLoading(), store.error(), store.query() // store.filteredUsers(), store.userCount() // store.loadUsers(), store.setQuery(), store.deleteUser() // — all available directly}Entity management — withEntities
For collections, withEntities from @ngrx/signals/entities provides
dictionary storage and auto-generated entities() computed signal:
import { withEntities, setAllEntities, addEntity, removeEntity, updateEntity } from '@ngrx/signals/entities';import { signalStore, withMethods, withProps, patchState } from '@ngrx/signals';
export const ProductsStore = signalStore( { providedIn: 'root' }, withEntities<Product>(), // adds: ids, entityMap, entities (computed)
withProps(() => ({ _api: inject(ProductsApiService), })),
withMethods((store) => ({ load: rxMethod<void>( pipe( switchMap(() => store._api.getAll().pipe( tapResponse({ next: products => patchState(store, setAllEntities(products)), error: console.error, }) )) ) ),
add: rxMethod<CreateProductDto>( pipe( switchMap(dto => store._api.create(dto).pipe( tapResponse({ next: product => patchState(store, addEntity(product)), error: console.error, }) )) ) ),
remove(id: string): void { patchState(store, removeEntity(id)); // optimistic — no async }, })));<!-- Template — entities() is a computed Signal<Product[]> -->@for (product of store.entities(); track product.id) { <app-product [product]="product" (remove)="store.remove(product.id)" />}Component-scoped stores
Omit { providedIn: 'root' } and provide the store in the component —
the store is created per component instance and destroyed with it:
@Component({ selector: 'app-search', standalone: true, providers: [SearchStore], // instance-scoped — created and destroyed with this component template: `...`,})export class SearchComponent { store = inject(SearchStore);}Use component-scoped stores for wizard steps, multi-step forms, or any UI where state doesn't need to outlive the component.
Custom reusable features
signalStoreFeature() lets you extract a pattern into a reusable feature
that can be composed into any store:
// with-request-status.ts — reusable loading/error featureimport { signalStoreFeature, withState, withComputed, patchState } from '@ngrx/signals';import { computed } from '@angular/core';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
export function withRequestStatus() { return signalStoreFeature( withState({ requestStatus: 'idle' as RequestStatus }), withComputed(({ requestStatus }) => ({ isPending: computed(() => requestStatus() === 'pending'), isFulfilled: computed(() => requestStatus() === 'fulfilled'), error: computed(() => { const s = requestStatus(); return typeof s === 'object' ? s.error : null; }), })) );}
// Helper updaters — use these in patchState() callsexport const setPending = (): Partial<{ requestStatus: RequestStatus }> => ({ requestStatus: 'pending' });export const setFulfilled = (): Partial<{ requestStatus: RequestStatus }> => ({ requestStatus: 'fulfilled' });export const setError = (error: string): Partial<{ requestStatus: RequestStatus }> => ({ requestStatus: { error } });// Compose into any storeexport const UsersStore = signalStore( { providedIn: 'root' }, withState({ users: [] as User[] }), withRequestStatus(), // adds isPending, isFulfilled, error signals withMethods(store => ({ load: rxMethod<void>(pipe( tap(() => patchState(store, setPending())), switchMap(() => usersService.getAll().pipe( tapResponse({ next: users => patchState(store, { users }, setFulfilled()), error: (e: Error) => patchState(store, setError(e.message)), }) )) )), })));Real-world patterns
Pattern 1 — Reactive search with rxMethod connected to a signal
rxMethod accepts a signal directly — when the signal changes, the method
re-runs automatically. This is the cleanest way to drive data loading from
a reactive input:
withMethods((store) => ({ // rxMethod can accept: a value, a Signal<T>, or an Observable<T> searchByQuery: rxMethod<string>( pipe( debounceTime(300), distinctUntilChanged(), tap(() => patchState(store, setPending())), switchMap(query => store._api.search(query).pipe( tapResponse({ next: results => patchState(store, { results }, setFulfilled()), error: (e: Error) => patchState(store, setError(e.message)), }) ) ) ) ),})),
withHooks({ onInit(store): void { // Connect the query SIGNAL to the rxMethod — auto-searches on every change store.searchByQuery(store.query); // passing signal directly — not store.query() },})Pattern 2 — tapResponse vs catchError
Never use catchError inside an rxMethod pipeline — it completes the outer
stream, which means the method stops working after the first error:
// ❌ catchError completes the outer stream — method dies after first failureloadUsers: rxMethod<void>( pipe( switchMap(() => usersService.getAll().pipe( map(users => patchState(store, { users })), catchError(err => { patchState(store, { error: err.message }); return EMPTY; }) // ^ after this error, the rxMethod stops listening for future calls )) )),
// ✅ tapResponse handles error without completing the outer streamloadUsers: rxMethod<void>( pipe( switchMap(() => usersService.getAll().pipe( tapResponse({ next: users => patchState(store, { users }), error: (e: Error) => patchState(store, { error: e.message }), // outer rxMethod keeps running after this error — future calls work }) )) )),Common mistakes
Mistake 1 — Mutating state directly instead of patchState
Signal Store's state is deep-frozen in dev mode. Mutating it directly throws:
// ❌ Throws in dev mode — state is immutablewithMethods(store => ({ addUser(user: User): void { store.users().push(user); // mutation — error in dev mode }})),
// ✅ patchState produces a new objectwithMethods(store => ({ addUser(user: User): void { patchState(store, state => ({ users: [...state.users, user] })); }})),Mistake 2 — Injecting services in withMethods instead of withProps
withMethods receives the store object — inject() must be called outside of it,
in withProps(), which runs in the injection context:
// ❌ inject() in withMethods — may fail depending on execution contextwithMethods(store => ({ load: rxMethod<void>(pipe( switchMap(() => inject(UserService).getAll().pipe(...)) // unreliable ))})),
// ✅ inject() in withProps — guaranteed injection contextwithProps(() => ({ _userService: inject(UserService) })),withMethods(store => ({ load: rxMethod<void>(pipe( switchMap(() => store._userService.getAll().pipe(...)) // correct ))})),Mistake 3 — Passing store.signal() instead of store.signal to rxMethod
rxMethod accepts a Signal<T> reference — not the signal's current value.
Passing store.query() gives rxMethod a single string, not reactive tracking:
withHooks({ onInit(store): void { store.search(store.query()); // ❌ passes the current string value — static store.search(store.query); // ✅ passes the Signal<string> — reactive }})Mistake 4 — Using signalStore when signals alone are enough
Signal Store adds structure and DI integration. For simple shared state with no async, a plain service with signals is simpler:
// ❌ Signal Store overkill for three signals and two methodsexport const ThemeStore = signalStore( { providedIn: 'root' }, withState({ theme: 'light' as 'light' | 'dark' }), withMethods(store => ({ toggle(): void { patchState(store, s => ({ theme: s.theme === 'light' ? 'dark' : 'light' })); } })));
// ✅ Plain injectable signal service is cleaner for simple cases@Injectable({ providedIn: 'root' })export class ThemeService { readonly theme = signal<'light' | 'dark'>('light'); toggle(): void { this.theme.update(t => t === 'light' ? 'dark' : 'light'); }}Reach for Signal Store when you need: rxMethod for async, withHooks for
lifecycle, withEntities for collections, or custom reusable features. For
1–3 signals with simple sync methods, a plain service is fine.
How this evolved
-
NgRx v16 (2023):
@ngrx/signalsintroduced in developer preview alongsidestore.selectSignal(). First release ofsignalStore,withState,withComputed,withMethods,patchState. -
NgRx v17 (2023):
withEntitiesfrom@ngrx/signals/entitiesintroduced.rxMethodstabilized.tapResponseoperator added to@ngrx/operators. -
NgRx v18 (2024):
withPropsadded — clean injection context for private dependencies.signalStoreFeatureimproved type inference. -
NgRx v19 (2024): Signal Store graduated to stable. Community DevTools integration via
@angular-architects/ngrx-toolkit(withDevtools) matured.signalStateintroduced as a lightweight alternative tosignalStorefor stores that don't need DI or lifecycle hooks. -
Angular 22 / NgRx ~v20 (now): NgRx Signal Store is the recommended choice for feature-level and mid-size application state in new Angular projects. NgRx Store remains for applications that need strict Redux conventions, full official DevTools, and complex action-based orchestration. The two can coexist in the same app.
See also
- NgRx Store — the Redux-pattern alternative for apps that need strict conventions, full DevTools, and action audit trails
- Signals — the Angular primitive Signal Store builds on; plain signal services for simpler cases
- NGXS — class-based state management, an alternative pattern
- toSignal & toObservable — bridging rxMethod outputs with Observable-based code
- Official docs — NgRx Signal Store
- Official docs — withEntities
- Official docs — rxMethod