Angular 85 🅰️ Signal Store
The previous chapters covered the global NgRx store with actions, reducers, and effects, and then ComponentStore as the component-scoped alternative. Signal Store (@ngrx/signals) is the newest entry in the NgRx ecosystem, and it is the one NgRx now recommends for new projects. It combines the structure and composability of NgRx with the reactivity of Angular signals, eliminating actions, reducers, and selectors in favor of a functional, feature-based API.
Signal Store is not a replacement for the classic store. It is a different tool for a different scope. Where the classic store is designed for application-wide state with time-travel debugging and a single source of truth, Signal Store is designed for feature and component state with minimal boilerplate and native signal integration . The two can coexist: the classic store for the global state that benefits from Redux DevTools, and Signal Store for the local state that benefits from signals.
Key point: A Signal Store is a tree of composed features. Each feature contributes some combination of state, computed signals, methods, and lifecycle hooks. Composition happens at definition time with signalStore(...). The store is an Angular provider: inject it with inject(Store) or TestBed.inject(Store). State updates go through patchState(store, updater). Direct mutation does not exist .
Why Signal Store exists
The classic store and ComponentStore both solve real problems. Signal Store solves the problem that neither was designed for: state management that feels native to Angular’s signal-based reactivity.
The boilerplate problem. The classic store requires actions, reducers, selectors, and effects for every feature. A simple counter needs four files. Signal Store reduces this to a single signalStore() call with withState, withComputed, and withMethods. There are no action names to invent, no reducer switch statements, and no selector memoization to configure .
The signals-native problem. The classic store and ComponentStore are built on RxJS. select returns observables. effect returns observables. Angular’s modern change detection is signal-based, and mixing observables with signals requires toSignal or the async pipe. Signal Store’s state is signals by default. Reading state is store.count(), not store.select(selectCount) | async. Computed values are computed(), not memoized selector functions .
The composition problem. ComponentStore uses class inheritance: you extend ComponentStore<T>. Signal Store uses function composition: you compose features with signalStore(withState(...), withComputed(...), withMethods(...)). Features can be reused across stores via signalStoreFeature(), and custom features can declare prerequisites so that misuse is a compile error .
The scoping problem. Signal Store can be provided at root, on a route, or on a component. The scope determines the lifetime. { providedIn: 'root' } for application-wide state. Route providers for feature state that should be discarded when the route deactivates. Component providers for state that lives and dies with a single component tree .
The trade-off. Signal Store is new. Its ecosystem is smaller than the classic store’s. DevTools support is less mature. The Redux pattern of “actions describe events” is absent — Signal Store methods mutate state directly, which is more concise but loses the event log. For applications that need time-travel debugging or an event-sourced architecture, the classic store remains the right choice .
a. Defining a Signal Store
A Signal Store is created with the signalStore function. It takes a sequence of features. The most common features are withState, withComputed, and withMethods. The store is an Angular provider: it can be provided at root, on a route, or on a component.
import { computed, inject } from '@angular/core';
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from '@ngrx/signals';
import { BookApi } from './book.api';
import { Book } from './book.model';
type BookSearchState = {
books: Book[];
isLoading: boolean;
filter: { query: string; order: 'asc' | 'desc' };
};
const initialState: BookSearchState = {
books: [],
isLoading: false,
filter: { query: '', order: 'asc' },
};
export const BookSearchStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ books, filter }) => ({
booksCount: computed(() => books().length),
sortedBooks: computed(() => {
const direction = filter().order === 'asc' ? 1 : -1;
return [...books()].sort(
(a, b) => direction * a.title.localeCompare(b.title)
);
}),
})),
withMethods((store, api = inject(BookApi)) => ({
updateQuery(query: string): void {
patchState(store, (state) => ({
filter: { ...state.filter, query },
}));
},
updateOrder(order: 'asc' | 'desc'): void {
patchState(store, (state) => ({
filter: { ...state.filter, order },
}));
},
async loadBooks(): Promise<void> {
patchState(store, { isLoading: true });
try {
const books = await api.getBooks();
patchState(store, { books, isLoading: false });
} catch {
patchState(store, { isLoading: false });
}
},
}))
);
The withState feature declares the state shape and initial values. Each property becomes a signal on the store instance: store.books(), store.isLoading(), store.filter(). Nested objects become deep signals: store.filter.query() and store.filter.order() are also readable .
The withComputed feature declares derived values. The factory receives the store’s previously defined state signals and returns a dictionary of computed signals. booksCount and sortedBooks are computed from books and filter. They update automatically when their dependencies change .
The withMethods feature declares methods. The factory receives the store instance and returns a dictionary of methods. The methods call patchState to update the state. loadBooks is an async method that sets isLoading, calls the API, and patches the result. The API is injected within the factory function, so it is available to every method that needs it .
The component consumes the store by injecting it:
import { Component, inject } from '@angular/core';
import { BookSearchStore } from './book-search.store';
@Component({
selector: 'book-search',
template: `
<p>Count: {{ store.booksCount() }}</p>
<input
[value]="store.filter.query()"
(input)="store.updateQuery($event.target.value)"
/>
@for (book of store.sortedBooks(); track book.id) {
<div>{{ book.title }}</div>
}
`,
})
export class BookSearchComponent {
readonly store = inject(BookSearchStore);
}
The component reads signals directly: store.booksCount(), store.filter.query(), store.sortedBooks(). There are no observables, no async pipe, no subscriptions. The signals are read in the template, and Angular’s change detection handles the rest .
b. Entity Management with withEntities
The @ngrx/signals/entities package provides withEntities, the Signal Store equivalent of the classic store’s Entity adapter. It normalizes a collection into an entity map and provides update functions for CRUD operations. The state shape is similar: { ids: [], entityMap: {} }, with entities as a computed array of the entities in order .
import { computed } from '@angular/core';
import { signalStore, withMethods, patchState } from '@ngrx/signals';
import {
withEntities,
setAllEntities,
addEntity,
updateEntity,
removeEntity,
entityConfig,
} from '@ngrx/signals/entities';
import { Account } from './account.model';
const accountConfig = entityConfig({
entity: type<Account>(),
collection: 'accounts',
selectId: (account) => account.code,
});
export const AccountStore = signalStore(
{ providedIn: 'root' },
withEntities(accountConfig),
withComputed(({ accountsEntities }) => ({
activeAccounts: computed(() =>
accountsEntities().filter((a) => a.isActive)
),
totalBalance: computed(() =>
accountsEntities().reduce((sum, a) => sum + a.balance, 0)
),
})),
withMethods((store) => ({
setAccounts(accounts: Account[]): void {
patchState(store, setAllEntities(accounts, accountConfig));
},
addAccount(account: Account): void {
patchState(store, addEntity(account, accountConfig));
},
updateBalance(code: string, balance: number): void {
patchState(
store,
updateEntity({ id: code, changes: { balance } }, accountConfig)
);
},
removeAccount(code: string): void {
patchState(store, removeEntity(code, accountConfig));
},
}))
);
The entityConfig declares the entity type, an optional collection name (for multiple entity collections in one store), and a custom selectId function. The withEntities(accountConfig) feature adds the entity state. The store exposes accountsIds(), accountsEntityMap(), and accountsEntities() as signals .
The update functions — setAllEntities, addEntity, updateEntity, removeEntity — are passed to patchState. They produce the partial state that patchState merges. The accountConfig is passed to both withEntities and every update function. This repetition is load-bearing: an updater called without the config falls back to reading entity.id, which may be undefined for entities with custom IDs .
The entity selectors are computed from the normalized state. accountsEntities() returns the entities in the order of the ids array. Derived collections — activeAccounts, totalBalance — are computed signals that read from accountsEntities() and recalculate when the entity state changes.
c. Async Side Effects with rxMethod
Signal Store’s state is signals. Its side effects are handled by rxMethod, a function from @ngrx/signals/rxjs-interop that bridges signals and RxJS. rxMethod is used inside withMethods to define methods that perform async operations with RxJS operators .
import { inject } from '@angular/core';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap, tap } from 'rxjs';
import { TaskApi } from './task.api';
export const TasksStore = signalStore(
{ providedIn: 'root' },
withState({ tasks: [], isLoading: false, error: null as string | null }),
withMethods((store, api = inject(TaskApi)) => ({
loadTasks: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
api.getTasks().pipe(
tapResponse({
next: (tasks) => patchState(store, { tasks, isLoading: false }),
error: (error: Error) =>
patchState(store, { isLoading: false, error: error.message }),
})
)
)
)
),
}))
);
The rxMethod<void> declares a method that takes no input. The pipe configures the stream: tap sets the loading state, switchMap calls the API, and tapResponse handles the success and error cases. The tapResponse operator from @ngrx/operators keeps the stream alive after an error, which is essential — without it, an error would terminate the method permanently .
rxMethod can also accept a signal or an observable as input. When the input changes, the method re-executes. This is how reactive search is implemented:
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) =>
api.search(query).pipe(
tapResponse({
next: (results) => patchState(store, { results }),
error: () => patchState(store, { results: [] }),
})
)
)
)
)
The search method is called with a signal: store.search(querySignal). Every time querySignal changes, search runs again. The debounceTime and switchMap operators handle the common race conditions: debounceTime waits for the user to stop typing, and switchMap cancels the previous request when a new one arrives .
For simple side effects that do not need RxJS operators, signalMethod (introduced in NgRx v19) provides a lighter alternative. It accepts a signal or a value, runs a synchronous function, and does not require RxJS. The choice between them is whether the operation needs operators like debounceTime, switchMap, or retry .
Complete Example Session
This session builds a complete task management Signal Store with entity management, async effects, and a component that consumes it.
// ============================================
// PART 1: THE MODEL
// ============================================
// task.model.ts
export interface Task {
id: string;
title: string;
completed: boolean;
}
// ============================================
// PART 2: THE API SERVICE
// ============================================
// task.api.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Task } from './task.model';
@Injectable({ providedIn: 'root' })
export class TaskApi {
private http = inject(HttpClient);
getTasks(): Observable<Task[]> {
return this.http.get<Task[]>('/api/tasks');
}
createTask(task: Omit<Task, 'id'>): Observable<Task> {
return this.http.post<Task>('/api/tasks', task);
}
}
// ============================================
// PART 3: THE SIGNAL STORE
// ============================================
// tasks.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap, tap } from 'rxjs';
import { TaskApi } from './task.api';
import { Task } from './task.model';
type TasksState = {
isLoading: boolean;
error: string | null;
filter: 'all' | 'active' | 'completed';
};
export const TasksStore = signalStore(
{ providedIn: 'root' },
withState<TasksState>({
isLoading: false,
error: null,
filter: 'all',
}),
withEntities<Task>(),
withComputed(({ entities, filter }) => ({
filteredTasks: computed(() => {
const all = entities();
switch (filter()) {
case 'active': return all.filter((t) => !t.completed);
case 'completed': return all.filter((t) => t.completed);
default: return all;
}
}),
total: computed(() => entities().length),
completedCount: computed(() => entities().filter((t) => t.completed).length),
})),
withMethods((store, api = inject(TaskApi)) => ({
setFilter(filter: TasksState['filter']): void {
patchState(store, { filter });
},
addTask(task: Task): void {
patchState(store, addEntity(task));
},
toggleTask(id: string): void {
const task = store.entityMap()[id];
if (!task) return;
patchState(store, updateEntity({ id, changes: { completed: !task.completed } }));
},
removeTask(id: string): void {
patchState(store, removeEntity(id));
},
clearCompleted(): void {
const completedIds = store.entities()
.filter((t) => t.completed)
.map((t) => t.id);
for (const id of completedIds) {
patchState(store, removeEntity(id));
}
},
loadTasks: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
api.getTasks().pipe(
tapResponse({
next: (tasks) => patchState(store, setAllEntities(tasks), { isLoading: false }),
error: (error: Error) =>
patchState(store, { isLoading: false, error: error.message }),
})
)
)
)
),
}))
);
// ============================================
// PART 4: THE COMPONENT
// ============================================
// tasks.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { TasksStore } from './tasks.store';
@Component({
selector: 'tasks-page',
template: `
@if (store.isLoading()) {
<p class="loading">Loading...</p>
}
@if (store.error(); as error) {
<p class="error">{{ error }}</p>
}
<p>Total: {{ store.total() }} — Completed: {{ store.completedCount() }}</p>
<div class="filters">
<button (click)="store.setFilter('all')">All</button>
<button (click)="store.setFilter('active')">Active</button>
<button (click)="store.setFilter('completed')">Completed</button>
</div>
<ul>
@for (task of store.filteredTasks(); track task.id) {
<li>
<input
type="checkbox"
[checked]="task.completed"
(change)="store.toggleTask(task.id)"
/>
<span [class.completed]="task.completed">{{ task.title }}</span>
<button (click)="store.removeTask(task.id)">×</button>
</li>
}
</ul>
<button (click)="store.clearCompleted()">Clear completed</button>
<button (click)="store.loadTasks()">Reload</button>
`,
})
export class TasksPageComponent implements OnInit {
readonly store = inject(TasksStore);
ngOnInit(): void {
this.store.loadTasks();
}
}
// ============================================
// PART 5: THE TEST
// ============================================
// tasks.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { TasksStore } from './tasks.store';
import { TaskApi } from './task.api';
describe('TasksStore', () => {
let store: TasksStore;
let api: jasmine.SpyObj<TaskApi>;
beforeEach(() => {
api = jasmine.createSpyObj('TaskApi', ['getTasks', 'createTask']);
TestBed.configureTestingModule({
providers: [
TasksStore,
{ provide: TaskApi, useValue: api },
],
});
store = TestBed.inject(TasksStore);
});
it('loads tasks into the entity state', async () => {
const mockTasks = [
{ id: '1', title: 'Write tests', completed: false },
];
api.getTasks.and.returnValue(of(mockTasks));
store.loadTasks();
// rxMethod is async; use a tick or await
await new Promise((resolve) => setTimeout(resolve, 0));
expect(store.entities().length).toBe(1);
expect(store.isLoading()).toBe(false);
});
it('filters active tasks', () => {
store.addTask({ id: '1', title: 'A', completed: false });
store.addTask({ id: '2', title: 'B', completed: true });
store.setFilter('active');
expect(store.filteredTasks().length).toBe(1);
expect(store.filteredTasks()[0].completed).toBe(false);
});
it('toggles a task', () => {
store.addTask({ id: '1', title: 'A', completed: false });
store.toggleTask('1');
const task = store.entityMap()['1'];
expect(task?.completed).toBe(true);
});
it('handles load errors', async () => {
api.getTasks.and.returnValue(throwError(() => new Error('Network error')));
store.loadTasks();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(store.isLoading()).toBe(false);
expect(store.error()).toBe('Network error');
});
});
The five parts cover the model, the API service, the Signal Store with entity management and rxMethod, the consuming component, and the test.
Quick Reference
The Core Features
| Feature | Purpose |
|---|---|
signalStore(...) | Define a store |
withState(initial) | Declare state signals |
withComputed(fn) | Declare derived signals |
withMethods(fn) | Declare methods and effects |
withProps(fn) | Add static properties |
withHooks(fn) | Lifecycle hooks (onInit, onDestroy) |
withEntities(config) | Add entity collection |
signalStoreFeature(...) | Create a reusable custom feature |
The Entity Update Functions
| Function | Purpose |
|---|---|
setAllEntities(items, config) | Replace all |
addEntity(item, config) | Add one |
updateEntity({ id, changes }, config) | Update one |
removeEntity(id, config) | Remove one |
removeEntities(ids, config) | Remove many |
updateAllEntities(fn, config) | Update all |
The Async Side Effect Tools
| Tool | RxJS Required | Use Case |
|---|---|---|
async method | No | Single awaited call |
rxMethod(input, pipe) | Yes | Debounce, switchMap, cancellation |
signalMethod(input, fn) | No | Simple synchronous side effects |
The Scope Options
| Scope | Declaration | Lifetime |
|---|---|---|
| Root | { providedIn: 'root' } | Application |
| Route | Route providers | Route activation |
| Component | Component providers | Component |
Best Practices
✅ Do This:
// Type the state explicitly
withState<TasksState>(initialState) // ✅
// Use patchState for all updates
patchState(store, { isLoading: true }) // ✅
// Use tapResponse in rxMethod
tapResponse({ next: ..., error: ... }) // ✅
// Pass entityConfig to every updater
setAllEntities(tasks, accountConfig) // ✅
// Scope the store to its lifetime
{ providedIn: 'root' } // or route/component providers // ✅
❌ Don’t Do This:
// Don't mutate state directly
store.tasks().push(task) // ❌
// Don't forget patchState in rxMethod
switchMap(() => api.get()) // no patchState // ❌
// Don't omit catchError or tapResponse
switchMap(() => api.get()) // stream dies on error // ❌
// Don't put HTTP calls in the store without a service
withMethods((store) => ({ load() { inject(HttpClient).get(...) } })) // ❌ use a service
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| State doesn’t update | Mutated instead of patchState | Use patchState |
| Effect stream dies | No tapResponse or catchError | Use tapResponse |
| Entity update wrong | entityConfig not passed | Pass config to every updater |
| Infinite loop | rxMethod writes to a signal it reads | Guard with a condition |
| Test hangs | rxMethod async not awaited | Await the tick or use fakeAsync |
| Store not shared | Provided in two scopes | Provide in only one |
Real-World Examples
1. Basic Signal Store
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState({ count: 0 }),
withMethods((store) => ({
increment: () => patchState(store, (s) => ({ count: s.count + 1 })),
}))
);
2. Computed from State
withComputed(({ count }) => ({ double: computed(() => count() * 2) }))
3. Async Method
load: rxMethod<void>(pipe(switchMap(() => api.get().pipe(tapResponse({ ... })))))
4. Entity Collection
withEntities<User>({ selectId: (u) => u.id })
5. Entity Update
patchState(store, addEntity(user))
6. Custom Entity Config
const config = entityConfig({ entity: type<User>(), selectId: (u) => u.code });
7. Store Scope
{ providedIn: 'root' } // or providers: [Store]
8. Lifecycle Hook
withHooks({ onInit() { ... }, onDestroy() { ... } })
9. Reusable Feature
function withLogger() { return signalStoreFeature(withMethods(...)); }
10. Test Injection
store = TestBed.inject(TasksStore);
Visual
The Signal Store Composition
┌──────────────────────────────────────────────┐
│ SIGNAL STORE COMPOSITION │
│ │
│ signalStore( │
│ { providedIn: 'root' }, │
│ withState(initialState), │
│ withComputed(fn), │
│ withMethods(fn), │
│ withEntities(config), │
│ withHooks(fn), │
│ ) │
│ │
│ Each feature contributes: │
│ ├─ state signals │
│ ├─ computed signals │
│ ├─ methods │
│ └─ lifecycle hooks │
│ │
│ The result is a single injectable provider. │
│ │
└──────────────────────────────────────────────┘
The Entity State Shape
┌──────────────────────────────────────────────┐
│ SIGNAL STORE ENTITY STATE │
│ │
│ withEntities<Task>() │
│ ├─ ids: Signal<string[]> │
│ ├─ entityMap: Signal<Record<string, Task>>│
│ └─ entities: Signal<Task[]> │
│ │
│ Derived: │
│ computed(() => entities().length) │
│ computed(() => entities().filter(...)) │
│ │
│ Updates: │
│ patchState(store, addEntity(task)) │
│ patchState(store, updateEntity({ id, changes }))│
│ │
└──────────────────────────────────────────────┘
The rxMethod Flow
┌──────────────────────────────────────────────┐
│ rxMethod FLOW │
│ │
│ store.loadTasks() │
│ │ │
│ ▼ │
│ pipe( │
│ tap(() => patchState({ isLoading: true })),│
│ switchMap(() => api.get()), │
│ tapResponse({ │
│ next: (data) => patchState({ data }), │
│ error: (err) => patchState({ error }), │
│ }) │
│ ) │
│ │ │
│ ▼ │
│ rxMethod auto-unsubscribes on destroy. │
│ │
└──────────────────────────────────────────────┘
The Scope Decision
┌──────────────────────────────────────────────┐
│ SCOPE DECISION │
│ │
│ App-wide? │
│ └─ { providedIn: 'root' } │
│ │
│ Route-specific? │
│ └─ providers: [Store] on the route │
│ │
│ Component-specific? │
│ └─ providers: [Store] on the component │
│ │
│ The narrower the scope, the sooner the │
│ state is destroyed. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Package | @ngrx/signals |
| Entity package | @ngrx/signals/entities |
| RxJS interop | @ngrx/signals/rxjs-interop |
| Store definition | signalStore(...) |
| State | withState(initial) |
| Computed | withComputed(fn) |
| Methods | withMethods(fn) |
| Entities | withEntities(config) |
| Async effects | rxMethod(input, pipe) |
| State update | patchState(store, updater) |
| Scope | Root, route, or component |
| Test | TestBed.inject(Store) |
Key takeaways:
- Signal Store is NgRx’s recommended approach for new feature and component state. It combines the structure of NgRx with Angular’s signal reactivity. There are no actions, reducers, or selectors — state is signals, computed values are
computed(), and methods callpatchState. - A Signal Store is a tree of composed features.
signalStore(withState(...), withComputed(...), withMethods(...))composes the store from independent features. Reusable features are built withsignalStoreFeature(). Composition replaces inheritance . - State updates go through
patchState. Direct mutation does not exist.patchState(store, partial)merges a partial state,patchState(store, (state) => partial)derives the partial from the current state, and custom updaters likeaddEntityproduce the partial . withEntitiesprovides normalized collection management. The state is{ ids: [], entityMap: {} }. The update functions —setAllEntities,addEntity,updateEntity,removeEntity— are passed topatchState. TheentityConfigmust be passed to every updater .rxMethodhandles async side effects with RxJS. It is used insidewithMethodsto define methods that accept a value, signal, or observable. The input changes trigger re-execution.tapResponsehandles success and error without terminating the stream .- The store is an Angular provider. Provide it at root for application state, on a route for feature state, or on a component for component-scoped state. The scope determines the lifetime .
- Signal Store does not replace the classic store. The classic store remains the right choice for complex event-driven architectures with time-travel debugging. Signal Store is for most feature and component state. The two can coexist in the same application .
Remember: Signal Store is the modern NgRx API for state that belongs to a feature or a component. It is signals-native, composable, and has far less boilerplate than the classic store. State lives in withState, derived values in withComputed, mutations in withMethods, collections in withEntities, and async effects in rxMethod. Updates go through patchState. The store is an Angular provider, so its lifetime is whatever scope you give it. Use it for the 80% of state that does not need the full Redux machinery. Keep the classic store for the 20% that does.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!