Skip to main content

@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 primitiveObservables (select)Signals
BoilerplateHigh — actions, reducers, selectors, effects, feature modulesLow — one signalStore() call
Reading statestore.select(...) + async pipe / subscribeCall the signal: store.count()
Updating stateDispatch action → reducer returns new statepatchState(store, {...}) inside a method
Async side effects@ngrx/effects (extra package)rxMethod in @ngrx/signals/rxjs-interop
Change detectionWorks with async pipe / OnPushFine-grained, works great with zoneless
Learning curveSteep (Redux mental model)Gentle (just functions + signals)
Type inferenceManual typing in many placesStrong inference out of the box
DevToolsFull Redux DevToolsOptional 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 own withX()) 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:

  1. rxMethod — for reactive, stream-based side effects (great for API calls).
  2. 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 outer pipe, the whole rxMethod stream completes and stops reacting to future calls. Keeping catchError in 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.onInit runs once when the store is created — the perfect place to register effect()s.
  • getState(store) returns the full state snapshot; reading it inside an effect tracks 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 effectRecommended approach
API call triggered by an action/eventrxMethod with switchMap / exhaustMap
API call reacting to a changing signalrxMethod fed with a signal
Logging state changeseffect() inside withHooks.onInit
Persisting to localStorage / sessionStorageeffect() inside withHooks.onInit
One-off setup / teardownwithHooks.onInit / onDestroy
Reusable cross-cutting effectCustom signalStoreFeature

🔑 Key Concepts Recap

FunctionPurpose
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 withComputed for derived data. Don't store what you can compute (e.g., remaining count).
  • Use rxMethod for async. It handles subscription lifecycle for you — no manual cleanup.
  • Handle errors inside the inner Observable. Keep catchError within switchMap/concatMap so a failed request doesn't kill the whole rxMethod stream.
  • Register effect()s in withHooks.onInit. Use them for logging and persistence; they auto-clean up when the store is destroyed.
  • Guard browser APIs. Wrap localStorage access 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.