NgRx SignalStore (introduced in NgRx 17) combines NgRx’s structured state management with Angular Signals — providing type-safe, reactive state without RxJS Observables. It uses a composable, functional API: withState() for state definition, withComputed() for derived signals, withMethods() for mutations and async operations, and withHooks() for lifecycle. For most Angular 18 projects, NgRx SignalStore strikes the right balance between structure (explicit state, testable methods) and simplicity (no actions/reducers boilerplate).
NgRx SignalStore
// npm install @ngrx/signals
import { signalStore, withState, withComputed, withMethods,
withHooks, patchState } from '@ngrx/signals';
import { withEntities, setAllEntities, addEntity,
removeEntity, updateEntity } from '@ngrx/signals/entities';
import { computed, inject } from '@angular/core';
// ── PostsStore — using SignalStore ────────────────────────────────────────
export const PostsStore = signalStore(
{ providedIn: 'root' }, // or omit for component-level
// ── State ──────────────────────────────────────────────────────────────
withEntities<PostSummaryDto>(), // adds entities, ids, entityMap signals
withState({
loading: false,
error: null as string | null,
total: 0,
page: 1,
query: '',
}),
// ── Computed — derived signals ─────────────────────────────────────────
withComputed(({ entities, loading, error, total }) => ({
publishedPosts: computed(() => entities().filter(p => p.isPublished)),
isEmpty: computed(() => !loading() && entities().length === 0),
hasError: computed(() => error() !== null),
hasMore: computed(() => entities().length < total()),
})),
// ── Methods — mutations and async operations ───────────────────────────
withMethods((store, api = inject(PostsApiService)) => ({
// Sync: patch state directly
setPage(page: number): void {
patchState(store, { page });
},
setQuery(query: string): void {
patchState(store, { query, page: 1 });
},
// Async: inline HTTP, patchState on result
async loadPosts(): Promise<void> {
patchState(store, { loading: true, error: null });
try {
const result = await firstValueFrom(
api.getPublished(store.page(), 10, store.query())
);
patchState(store,
setAllEntities(result.items, { idKey: 'id' }),
{ loading: false, total: result.total }
);
} catch (err: any) {
patchState(store, { loading: false, error: err.message });
}
},
async deletePost(id: number): Promise<void> {
patchState(store, removeEntity(id)); // optimistic
try {
await firstValueFrom(api.delete(id));
patchState(store, { total: store.total() - 1 });
} catch {
// rollback — reload
this.loadPosts();
}
},
})),
// ── Lifecycle hooks ────────────────────────────────────────────────────
withHooks({
onInit(store) {
// Load on store initialisation
store.loadPosts();
},
}),
);
// ── Type inference — TypeScript knows the full shape ──────────────────────
type PostsStoreType = InstanceType<typeof PostsStore>;
// postsStore.entities() → PostSummaryDto[]
// postsStore.loading() → boolean
// postsStore.publishedPosts() → PostSummaryDto[]
// postsStore.loadPosts() → Promise<void>
// ── Component using SignalStore ────────────────────────────────────────────
@Component({
selector: 'app-post-list',
standalone: true,
template: `
@if (store.loading()) { <app-spinner /> }
@for (post of store.entities(); track post.id) {
<app-post-card [post]="post" />
} @empty {
@if (!store.loading()) { <p>No posts found.</p> }
}
`,
})
export class PostListComponent {
protected store = inject(PostsStore);
}
patchState(store, ...) is the SignalStore equivalent of a reducer — it merges the provided partial state into the current state. Unlike Redux reducers (which replace the entire state), patchState only updates the specified keys. The entity helper functions (setAllEntities, addEntity, removeEntity, updateEntity) are used as arguments to patchState to update the normalised entity collection, equivalent to NgRx Entity adapter methods in traditional NgRx.withMethods in separate files via store composition) to improve build-time tree shaking and keep individual store files manageable.NgRx Primitive Decision Guide
| Primitive | Scope | Best For |
|---|---|---|
| Service + Signals | App | Simple shared state, small apps |
| ComponentStore | Component | Complex component state, RxJS-heavy |
| SignalStore | App or Component | Structured state, Angular 18 preferred |
| NgRx Store + Effects | App (global) | Complex cross-feature, large teams, DevTools |
Common Mistakes
Mistake 1 — Using mutable operations inside patchState (breaks Signal reactivity)
❌ Wrong — patchState(store, { entities: store.entities().push(post) }); push mutates array in place.
✅ Correct — use SignalStore entity helpers: patchState(store, addEntity(post)).
Mistake 2 — Providing SignalStore at root for per-component state
❌ Wrong — signalStore({ providedIn: 'root' }) for state that should be per-component instance.
✅ Correct — omit providedIn and add to providers: [] on the component for component-scoped stores.