@ngrx/signals: Modern Angular State Management with Signal Store
⚡ @ngrx/signals — State Management the Signal Way
@ngrx/signals is a modern, standalone state management library for Angular built on top of Angular Signals. Instead of the boilerplate-heavy Redux pattern (actions, reducers, effects, selectors), it gives you a compact, functional API centered around the SignalStore.
If you have used @ngrx/store and felt it was too much ceremony for simple state, @ngrx/signals is designed exactly for you.
📦 Installation
npm install @ngrx/signals
Requires Angular 16+ (Signals) — Angular 17+ recommended for the best experience.
🚀 How to Use — Creating a Signal Store
A SignalStore is created with the signalStore() function. You compose it from features like withState, withComputed, and withMethods.
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
// 1️⃣ Define the shape of your state
type CounterState = {
count: number;
step: number;
};
const initialState: CounterState = {
count: 0,
step: 1,
};
// 2️⃣ Build the store
export const CounterStore = signalStore(
{ providedIn: 'root' }, // makes it a singleton service
// Holds the state as signals
withState(initialState),
// Derived (computed) state
withComputed(({ count, step }) => ({
doubleCount: computed(() => count() * 2),
isEven: computed(() => count() % 2 === 0),
})),
// Methods that update state
withMethods((store) => ({
increment() {
patchState(store, { count: store.count() + store.step() });
},
decrement() {
patchState(store, { count: store.count() - store.step() });
},
setStep(step: number) {
patchState(store, { step });
},
reset() {
patchState(store, initialState);
},
}))
);
Consuming the Store in a Component
Because every piece of state is a signal, you can read it directly in the template — no async pipe, no subscriptions, no manual unsubscribing.
import { Component, inject } from '@angular/core';
import { CounterStore } from './counter.store';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ store.count() }}</p>
<p>Double: {{ store.doubleCount() }}</p>
<p>Is Even: {{ store.isEven() }}</p>
<button (click)="store.decrement()">-</button>
<button (click)="store.increment()">+</button>
<button (click)="store.reset()">Reset</button>
`,
})
export class CounterComponent {
// Inject like any Angular service
readonly store = inject(CounterStore);
}
That's the entire flow — state, derived values, and update logic in one file, consumed with plain signal calls.
🆚 @ngrx/signals vs @ngrx/store
| Aspect | @ngrx/store (Redux) | @ngrx/signals (Signal Store) |
|---|---|---|
| Core primitive | Observables (select) | Signals |
| Boilerplate | High — actions, reducers, selectors, effects, feature modules | Low — one signalStore() call |
| Reading state | store.select(...) + async pipe / subscribe | Call the signal: store.count() |
| Updating state | Dispatch action → reducer returns new state | patchState(store, {...}) inside a method |
| Async side effects | @ngrx/effects (extra package) | rxMethod in @ngrx/signals/rxjs-interop |
| Change detection | Works with async pipe / OnPush | Fine-grained, works great with zoneless |
| Learning curve | Steep (Redux mental model) | Gentle (just functions + signals) |
| Type inference | Manual typing in many places | Strong inference out of the box |
| DevTools | Full Redux DevTools | Optional via @ngrx/signals events / store devtools |
Why @ngrx/signals is often the better choice
- Far less boilerplate. No separate action/reducer/selector files. State, computed values, and updaters live together.
- No subscription management. Signals are synchronous and read directly in templates — no memory leaks from forgotten
unsubscribe(). - Fine-grained reactivity. Only the specific view bindings that depend on a changed signal re-render, which pairs perfectly with zoneless Angular.
- Colocation. Everything about a slice of state is in one store definition, improving readability.
- Composable. Custom features (
withEntities, or your ownwithX()) let you share behavior across stores.
When to still use
@ngrx/store? Large apps that already rely on the Redux pattern, need strict time-travel debugging via Redux DevTools, or benefit from a strongly enforced unidirectional action log.
🧩 Managing State — A Practical Example (Todo List)
Let's build a realistic feature: a Todo list with loading state and an async fetch. This shows state, computed values, synchronous updates, and async side effects.
1. Define the Store
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed, inject } from '@angular/core';
import { pipe, switchMap, tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
type Todo = {
id: number;
title: string;
completed: boolean;
};
type TodosState = {
todos: Todo[];
loading: boolean;
filter: 'all' | 'active' | 'completed';
};
const initialState: TodosState = {
todos: [],
loading: false,
filter: 'all',
};
export const TodosStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
// 🔎 Derived state — automatically recomputes when dependencies change
withComputed(({ todos, filter }) => ({
filteredTodos: computed(() => {
const list = todos();
switch (filter()) {
case 'active':
return list.filter((t) => !t.completed);
case 'completed':
return list.filter((t) => t.completed);
default:
return list;
}
}),
remaining: computed(() => todos().filter((t) => !t.completed).length),
})),
withMethods((store, http = inject(HttpClient)) => ({
// ✏️ Synchronous state updates
addTodo(title: string) {
const newTodo: Todo = {
id: Date.now(),
title,
completed: false,
};
patchState(store, { todos: [...store.todos(), newTodo] });
},
toggleTodo(id: number) {
patchState(store, {
todos: store.todos().map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
});
},
removeTodo(id: number) {
patchState(store, { todos: store.todos().filter((t) => t.id !== id) });
},
setFilter(filter: TodosState['filter']) {
patchState(store, { filter });
},
// 🌐 Async side effect using rxMethod (replaces @ngrx/effects)
loadTodos: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap(() =>
http.get<Todo[]>('https://jsonplaceholder.typicode.com/todos').pipe(
tap((todos) => patchState(store, { todos, loading: false }))
)
)
)
),
}))
);
2. Use the Store in a Component
import { Component, inject } from '@angular/core';
import { TodosStore } from './todos.store';
@Component({
selector: 'app-todos',
standalone: true,
template: `
<h2>Todos ({{ store.remaining() }} remaining)</h2>
@if (store.loading()) {
<p>Loading…</p>
}
<input #box (keyup.enter)="add(box)" placeholder="New todo" />
<button (click)="store.loadTodos()">Load from API</button>
<div>
<button (click)="store.setFilter('all')">All</button>
<button (click)="store.setFilter('active')">Active</button>
<button (click)="store.setFilter('completed')">Completed</button>
</div>
<ul>
@for (todo of store.filteredTodos(); track todo.id) {
<li>
<input
type="checkbox"
[checked]="todo.completed"
(change)="store.toggleTodo(todo.id)"
/>
{{ todo.title }}
<button (click)="store.removeTodo(todo.id)">✕</button>
</li>
}
</ul>
`,
})
export class TodosComponent {
readonly store = inject(TodosStore);
add(box: HTMLInputElement) {
if (box.value.trim()) {
this.store.addTodo(box.value.trim());
box.value = '';
}
}
}
🌐 Handling Side Effects
State updates via patchState are pure and synchronous. Anything that reaches outside the store — HTTP calls, console.log, localStorage, timers, WebSockets — is a side effect. @ngrx/signals gives you two main tools for these:
rxMethod— for reactive, stream-based side effects (great for API calls).withHooks/effect— for reacting to signal changes (great for logging & persistence).
1️⃣ API Calls with rxMethod
rxMethod (from @ngrx/signals/rxjs-interop) turns an RxJS pipeline into a callable method and manages the subscription for you. Use RxJS flattening operators to control concurrency:
switchMap— cancel the previous request (typeahead / search).concatMap— queue requests in order.exhaustMap— ignore new requests while one is running (prevent double submit).mergeMap— run all in parallel.
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { pipe, switchMap, tap, catchError, of, debounceTime, distinctUntilChanged } from 'rxjs';
import { HttpClient } from '@angular/common/http';
type User = { id: number; name: string };
type UsersState = {
users: User[];
loading: boolean;
error: string | null;
};
const initialState: UsersState = { users: [], loading: false, error: null };
export const UsersStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store, http = inject(HttpClient)) => ({
// 🔍 Debounced search — cancels stale requests with switchMap
searchUsers: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, { loading: true, error: null })),
switchMap((query) =>
http.get<User[]>(`/api/users?q=${query}`).pipe(
tap((users) => patchState(store, { users, loading: false })),
// ⚠️ Handle the error INSIDE the inner pipe so the outer stream stays alive
catchError((err) => {
patchState(store, { error: err.message, loading: false });
return of([]); // recover gracefully
})
)
)
)
),
}))
);
Call it from a component — you can even pass a signal and it reacts automatically:
// Trigger once
store.searchUsers('john');
// Or bind reactively — re-runs whenever the signal changes
store.searchUsers(this.querySignal);
Why error handling goes inside
switchMap: if an error escapes to the outerpipe, the wholerxMethodstream completes and stops reacting to future calls. KeepingcatchErrorin the inner Observable isolates failures per request.
2️⃣ Logging & Persistence with withHooks and effect
For side effects that should run whenever state changes (not on an explicit method call), use Angular's effect() inside a lifecycle hook via withHooks.
import { signalStore, withState, withMethods, withHooks, patchState, getState } from '@ngrx/signals';
import { effect, inject } from '@angular/core';
type CartState = {
items: string[];
total: number;
};
const STORAGE_KEY = 'cart-state';
// Read initial state from localStorage (with a safe fallback)
function loadInitialState(): CartState {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : { items: [], total: 0 };
}
export const CartStore = signalStore(
{ providedIn: 'root' },
withState(loadInitialState()),
withMethods((store) => ({
addItem(item: string, price: number) {
patchState(store, {
items: [...store.items(), item],
total: store.total() + price,
});
},
})),
withHooks({
onInit(store) {
// 📝 Logging: runs every time any tracked signal changes
effect(() => {
console.log('[CartStore] state changed:', getState(store));
});
// 💾 Persistence: mirror state to localStorage on every change
effect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(getState(store)));
});
},
onDestroy(store) {
console.log('[CartStore] destroyed. Final state:', getState(store));
},
})
);
How it works:
withHooks.onInitruns once when the store is created — the perfect place to registereffect()s.getState(store)returns the full state snapshot; reading it inside aneffecttracks all state signals, so the effect re-runs on any change.effect()automatically cleans up when the store's injection context is destroyed — no manual unsubscribe.
3️⃣ Reusable Side Effects with a Custom Feature
You can package a side effect (like persistence) into a custom store feature and reuse it across stores:
import { signalStoreFeature, withHooks, getState } from '@ngrx/signals';
import { effect } from '@angular/core';
// A reusable "logger" feature
export function withLogger(name: string) {
return signalStoreFeature(
withHooks({
onInit(store) {
effect(() => console.log(`[${name}]`, getState(store)));
},
})
);
}
// Drop it into any store
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState({ count: 0 }),
withLogger('CounterStore') // ♻️ reusable across stores
);
🧭 Which tool for which side effect?
| Side effect | Recommended approach |
|---|---|
| API call triggered by an action/event | rxMethod with switchMap / exhaustMap |
| API call reacting to a changing signal | rxMethod fed with a signal |
| Logging state changes | effect() inside withHooks.onInit |
Persisting to localStorage / sessionStorage | effect() inside withHooks.onInit |
| One-off setup / teardown | withHooks.onInit / onDestroy |
| Reusable cross-cutting effect | Custom signalStoreFeature |
🔑 Key Concepts Recap
| Function | Purpose |
|---|---|
signalStore() | Creates the store and registers it as an injectable service. |
withState() | Defines the initial state; each property becomes a readable signal. |
withComputed() | Adds derived signals that auto-update when their dependencies change. |
withMethods() | Adds functions that read state and mutate it via patchState. |
patchState() | Immutably updates one or more state slices. |
rxMethod() | Bridges RxJS streams into the store for async side effects (from @ngrx/signals/rxjs-interop). |
withHooks() | Runs onInit / onDestroy lifecycle logic — the place to register effect()s. |
getState() | Returns the full state snapshot; tracks all state signals when read in an effect. |
signalStoreFeature() | Packages reusable state/methods/hooks into a shareable feature. |
✅ Best Practices
- Update immutably. Always create new arrays/objects in
patchState([...store.todos(), item]), never mutate in place. - Keep methods focused. One method = one intent (
addTodo,toggleTodo), which keeps the store readable and testable. - Use
withComputedfor derived data. Don't store what you can compute (e.g.,remainingcount). - Use
rxMethodfor async. It handles subscription lifecycle for you — no manual cleanup. - Handle errors inside the inner Observable. Keep
catchErrorwithinswitchMap/concatMapso a failed request doesn't kill the wholerxMethodstream. - Register
effect()s inwithHooks.onInit. Use them for logging and persistence; they auto-clean up when the store is destroyed. - Guard browser APIs. Wrap
localStorageaccess in try/catch (or an SSR check) so it doesn't break server-side rendering. - Provide at the right scope.
{ providedIn: 'root' }for global state, or provide the store in a component for local, feature-scoped state.
🎯 Summary
@ngrx/signals brings state management into the Signals era of Angular: less boilerplate, no manual subscriptions, and fine-grained reactivity that shines in zoneless apps. For most new Angular projects, the SignalStore is a simpler, more maintainable alternative to the classic @ngrx/store Redux setup — while still scaling up with computed state, methods, and RxJS-powered side effects when you need them.