NgRx SignalStore — Signal-Based State
NgRx SignalStore (introduced in NgRx 17) is a lightweight, signal-native state management solution. It combines the structure and best practices of NgRx with Angular Signals, offering dramatically less boilerplate than the classic NgRx Store while still being composable and testable.
SignalStore is built on Angular Signals — there are no Observables, no actions, no reducers, and no effects classes. State is reactive by default.
NgRx Store vs SignalStore
Feature | NgRx Store | SignalStore |
|---|---|---|
State primitive | Observable (BehaviorSubject) | Angular Signal |
Actions required | Yes | No |
Reducers required | Yes | No |
Selectors | createSelector() | computed() signals |
Side effects | createEffect() class | withMethods() + inject() |
DevTools | Redux DevTools | NgRx Signals DevTools (beta) |
Boilerplate | High | Low |
Best for | Large complex apps | Modern Angular 17+ apps |
Installation
npm install @ngrx/signals
Creating a Basic SignalStore
// src/app/store/counter.store.ts
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';
// Define the state interface
interface CounterState {
count: number;
step: number;
}
export const CounterStore = signalStore(
{ providedIn: 'root' }, // or provide per component/module
// withState — define initial state
withState<CounterState>({ count: 0, step: 1 }),
// withComputed — derived signals (like selectors)
withComputed(({ count, step }) => ({
doubled: computed(() => count() * 2),
canDecrement: computed(() => count() > 0),
nextValue: computed(() => count() + step()),
})),
// withMethods — state mutations and side effects
withMethods((store) => ({
increment(): void {
patchState(store, { count: store.count() + store.step() });
},
decrement(): void {
if (store.count() > 0) {
patchState(store, { count: store.count() - store.step() });
}
},
reset(): void {
patchState(store, { count: 0 });
},
setStep(step: number): void {
patchState(store, { step });
},
}))
);patchState(store, partialState) is the NgRx SignalStore helper for immutably updating state — similar to BehaviorSubject.next().Using the Store in a Component
// src/app/counter/counter.component.ts
import { Component, inject } from '@angular/core';
import { CounterStore } from '../store/counter.store';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Count: {{ store.count() }}</h2>
<p>Doubled: {{ store.doubled() }}</p>
<p>Next: {{ store.nextValue() }}</p>
<button (click)="store.decrement()" [disabled]="!store.canDecrement()">-</button>
<button (click)="store.increment()">+</button>
<button (click)="store.reset()">Reset</button>
<label>
Step:
<input type="number" [value]="store.step()"
(change)="store.setStep(+$event.target.value)" />
</label>
`,
})
export class CounterComponent {
store = inject(CounterStore);
}() and they integrate with the template change detection automatically. No async pipe needed.Practical Example: Products Store with HTTP
// src/app/store/products.store.ts
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { inject, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, catchError, EMPTY } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
export interface Product {
id: number;
name: string;
price: number;
category: string;
}
interface ProductsState {
products: Product[];
selectedId: number | null;
loading: boolean;
error: string | null;
filter: string;
}
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withState<ProductsState>({
products: [],
selectedId: null,
loading: false,
error: null,
filter: '',
}),
withComputed(({ products, selectedId, filter }) => ({
selectedProduct: computed(() =>
products().find(p => p.id === selectedId()) ?? null
),
filteredProducts: computed(() => {
const term = filter().toLowerCase();
return term
? products().filter(p => p.name.toLowerCase().includes(term))
: products();
}),
productCount: computed(() => products().length),
})),
withMethods((store, http = inject(HttpClient)) => ({
// Sync methods
selectProduct(id: number): void {
patchState(store, { selectedId: id });
},
setFilter(filter: string): void {
patchState(store, { filter });
},
removeProduct(id: number): void {
patchState(store, { products: store.products().filter(p => p.id !== id) });
},
// Async method using rxMethod
loadProducts: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
http.get<Product[]>('/api/products').pipe(
tapResponse({
next: (products) => patchState(store, { products, loading: false }),
error: (err: Error) =>
patchState(store, { error: err.message, loading: false }),
})
)
)
)
),
// Async method — create product
createProduct: rxMethod<Omit<Product, 'id'>>(
pipe(
switchMap((product) =>
http.post<Product>('/api/products', product).pipe(
tapResponse({
next: (created) =>
patchState(store, { products: [...store.products(), created] }),
error: (err: Error) =>
patchState(store, { error: err.message }),
})
)
)
)
),
}))
);Using the Products Store
// src/app/products/products.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { ProductsStore } from '../store/products.store';
@Component({
selector: 'app-products',
standalone: true,
imports: [FormsModule, CurrencyPipe],
template: `
@if (store.loading()) {
<div class="loading-bar">Loading...</div>
}
@if (store.error(); as error) {
<div class="error">{{ error }}</div>
}
<input
type="text"
placeholder="Filter products..."
[ngModel]="store.filter()"
(ngModelChange)="store.setFilter($event)"
/>
<p>Showing {{ store.filteredProducts().length }} of {{ store.productCount() }}</p>
@for (product of store.filteredProducts(); track product.id) {
<div class="card" (click)="store.selectProduct(product.id)">
<h3>{{ product.name }}</h3>
<p>{{ product.price | currency }}</p>
<button (click)="store.removeProduct(product.id); $event.stopPropagation()">
Remove
</button>
</div>
}
@if (store.selectedProduct(); as p) {
<aside>
<h2>Selected: {{ p.name }}</h2>
<p>{{ p.price | currency }} — {{ p.category }}</p>
</aside>
}
`,
})
export class ProductsComponent implements OnInit {
store = inject(ProductsStore);
ngOnInit(): void {
this.store.loadProducts();
}
}withHooks — Lifecycle Hooks
Use withHooks to run logic when the store is initialized or destroyed.
import { signalStore, withState, withMethods, withHooks } from '@ngrx/signals';
export const AppStore = signalStore(
{ providedIn: 'root' },
withState({ initialized: false }),
withMethods((store, http = inject(HttpClient)) => ({
load: rxMethod<void>(/* ... */),
})),
withHooks({
onInit(store): void {
console.log('Store initialized');
store.load(); // auto-load when store is first injected
},
onDestroy(store): void {
console.log('Store destroyed');
},
})
);Component-Level Store (Not Singleton)
Provide the store at component level for state that should be destroyed with the component.
// No 'providedIn: root' in the store
export const LocalStore = signalStore(
withState({ /* ... */ }),
withMethods(/* ... */)
);
// Provide in the component — each instance gets its own store
@Component({
providers: [LocalStore], // <-- component-scoped
template: '...',
})
export class MyComponent {
store = inject(LocalStore);
}Best Practices
Use withState() for all state definitions — never add plain properties to withMethods()
Derive computed values with withComputed() — never duplicate state
Use patchState() for all state updates — never mutate signal values directly
Use rxMethod() when methods need to handle Observables (HTTP calls)
Use withHooks() onInit to auto-load data when the store is first injected
Provide the store in root for app-wide state, in the component for local state
Keep each store focused on one domain (products, auth, cart) — separate files
Summary
NgRx SignalStore brings modern Angular Signals into the NgRx ecosystem, dramatically reducing boilerplate compared to classic NgRx Store. You get signal-based state, computed derivations, type-safe methods, and lifecycle hooks — all in a single signalStore() call. It's the recommended NgRx approach for new Angular 17+ applications that want structured state management without the ceremony of actions, reducers, and effects.