
Web State Ngrx Signalstore
- 9 installs
- 19 repo stars
- Updated July 19, 2026
- agents-inc/skills
web-state-ngrx-signalstore is a Claude Code skill for Angular Signals-based client state using NgRx SignalStore.
About
web-state-ngrx-signalstore is a Claude Code skill for reactive client-state management in Angular 17+ with NgRx SignalStore. A developer uses it to compose stores functionally instead of using traditional NgRx actions, reducers, and effects. It covers signalStore with withState, withComputed, and withMethods, immutable patchState updates, withEntities for collections, and rxMethod for RxJS side effects.
- Angular Signals state via signalStore with withState/withComputed/withMethods
- Immutable patchState updates and withEntities for collections
- rxMethod RxJS integration and custom signalStoreFeature composition
Web State Ngrx Signalstore by the numbers
- 9 all-time installs (skills.sh)
- Ranked #1,714 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
web-state-ngrx-signalstore capabilities & compatibility
- Capabilities
- client state management · entity management · computed state · rxjs effects
- Use cases
- frontend
- Pricing
- Free
What web-state-ngrx-signalstore says it does
Use NgRx SignalStore for reactive client state in Angular 17+.
You MUST use `patchState()` for ALL state updates - NEVER mutate state directly
You MUST wrap async operations in `rxMethod()` from `@ngrx/signals/rxjs-interop` for RxJS integration
npx skills add https://github.com/agents-inc/skills --skill web-state-ngrx-signalstoreAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 19, 2026 |
| Repository | agents-inc/skills ↗ |
What it does
Manage Angular 17+ client state with NgRx SignalStore, composable features, entities, and RxJS effects.
Who is it for?
Composable, reusable Angular 17+ client-state features with fine-grained Signals reactivity
Skip if: Server/API data as the primary source, simple component-local state, or Angular below 17
When should I use this skill?
Managing Angular state with Signals and SignalStore
What you get
Composable SignalStore features with immutable patchState updates, entities, and RxJS-integrated effects.
- signalStore with withState/withComputed/withMethods
- withEntities collection management
- rxMethod RxJS effects and custom signalStoreFeature
By the numbers
- 7 example files (core, entities, effects, features, testing, migration, plus reference)
Files
NgRx SignalStore Patterns
Quick Guide: Use NgRx SignalStore for reactive client state in Angular 17+. Compose stores withwithState,withComputed,withMethods. UsepatchStatefor immutable updates. UsewithEntitiesfor collections. NEVER use traditional NgRx patterns (actions, reducers, effects) in new SignalStore code.
Detailed Resources:
- examples/core.md - signalStore, withState, withComputed, withMethods, withProps
- examples/entities.md - withEntities, CRUD operations, prependEntity/upsertEntity (v20+)
- examples/effects.md - rxMethod, signalMethod (v19+), side effects
- examples/features.md - signalStoreFeature, custom features, DevTools
- examples/testing.md - Unit tests, unprotected(), mocking strategies
- examples/migration.md - Migration from traditional NgRx
- reference.md - Decision frameworks, anti-patterns, red flags
---
<critical_requirements>
CRITICAL: Before Managing State with NgRx SignalStore
(You MUST use `patchState()` for ALL state updates - NEVER mutate state directly)
(You MUST wrap async operations in `rxMethod()` from `@ngrx/signals/rxjs-interop` for RxJS integration)
(You MUST use `withEntities()` from `@ngrx/signals/entities` for entity collections - NOT arrays in state)
(You MUST use named exports ONLY - NO default exports in any store files)
(You MUST use named constants for ALL numbers - NO magic numbers in state code)
</critical_requirements>
---
Auto-detection: NgRx SignalStore, signalStore, withState, withComputed, withMethods, patchState, withEntities, rxMethod, signalStoreFeature, @ngrx/signals
When to use:
- Managing reactive client state in Angular 17+ applications
- Building composable, reusable store features
- Handling entity collections with CRUD operations
- Integrating RxJS operators for side effects
- Applications requiring fine-grained reactivity with Angular Signals
Key patterns covered:
- Store creation with
signalStore()andprovidedInoptions - State, computed, and methods composition
- Entity management with
withEntities - RxJS integration with
rxMethodand signal-onlysignalMethod - Custom features with
signalStoreFeatureand context-awarewithFeature(v20+) - Derived reactive state with
withLinkedState(v20+) - Call state patterns (loading, loaded, error)
When NOT to use:
- Server/API data as primary source (use HTTP services with rxMethod for caching)
- Simple component-local state (use Angular signals directly)
- Projects still on Angular < 17 (requires signals)
- Teams unfamiliar with functional composition patterns
---
<philosophy>
Philosophy
NgRx SignalStore is a lightweight, functional state management solution built on Angular Signals. It replaces traditional NgRx patterns (actions, reducers, effects, selectors) with a composable, feature-based approach that eliminates boilerplate while maintaining predictability.
Core Principles:
1. Functional Composition - Build stores by composing features like withState, withComputed, withMethods 2. Signal-Based Reactivity - Leverage Angular's fine-grained reactivity for optimal performance 3. Immutable Updates - Use patchState() for predictable state transitions 4. Extensibility - Create custom features with signalStoreFeature() for reuse across stores
Key Architecture Decisions:
1. `signalStore()` creates a fully typed store as an injectable Angular service 2. `patchState()` ensures immutable updates without Immer dependency 3. `withEntities()` provides standardized entity management (normalized ids/entityMap structure with built-in CRUD updaters) 4. `rxMethod()` bridges Angular Signals with RxJS for complex async flows 5. Protected state (v18+) prevents external mutations by default; v19+ applies deep freeze recursively
State Ownership:
| State Type | Solution | Reason |
|---|---|---|
| Server/API data | HTTP services + rxMethod | Caching in store, fetch via services |
| Shared client state | SignalStore | Reactivity, composition, DevTools |
| Component-local state | Angular signals | Simpler, no overhead |
| URL state (filters) | Router query params | Shareable, bookmarkable |
</philosophy>
---
<patterns>
Core Patterns
Pattern 1: Basic Store with signalStore
Create stores using signalStore() with withState, withComputed, and withMethods.
Feature Ordering
Features execute in order. State features must come first:
1. withState() - Define state 2. withComputed() - Derived values from state 3. withMethods() - Actions that update state 4. withHooks() - Lifecycle hooks (onInit, onDestroy)
For implementation examples, see examples/core.md.
---
Pattern 2: State Updates with patchState
Use patchState() for all state modifications. It ensures immutability and proper signal notifications.
When to Use
- Synchronous state updates
- Partial state updates (spread not needed)
- Inside
withMethods()and custom features
Key Behaviors
- Accepts partial state object or updater functions
- Multiple updaters can be passed to single call
- Works with entity updaters from
@ngrx/signals/entities
For implementation examples, see examples/core.md.
---
Pattern 3: Entity Management with withEntities
Use withEntities() for collections of items with IDs. Provides standardized CRUD operations and efficient lookups.
When to Use
- Lists of items with unique identifiers
- CRUD operations on collections
- Need for efficient ID-based lookups
- Multiple entity collections in one store
Available Updaters
setAllEntities()- Replace all entitiesaddEntity()/addEntities()- Add new entitiessetEntity()/setEntities()- Upsert entitiesupdateEntity()/updateEntities()- Partial updatesremoveEntity()/removeEntities()- Delete entities
For implementation examples, see examples/entities.md.
---
Pattern 4: RxJS Integration with rxMethod
Use rxMethod() for side effects that need RxJS operators (debounce, switchMap, etc.).
When to Use
- Debounced search/filtering
- Cancellable HTTP requests
- Complex async flows with multiple operators
- Reactive streams from signals
Key Behaviors
- Accepts
Observable<T>,Signal<T>, orTas input - Factory function receives
Observable<T>for piping - Runs in injection context (can use
inject()) - Auto-unsubscribes on store destroy
For implementation examples, see examples/effects.md.
---
Pattern 4b: Signal-Only Side Effects with signalMethod (v19+)
Use signalMethod() for side effects that don't need RxJS operators.
When to Use
- Simple side effects without RxJS dependency
- When you want to work purely with Signals
- Side effects that don't require debounce/switchMap/cancellation
- When injection context is not available
Key Behaviors
- Accepts
Signal<T>orTas input (no Observable) - Processor function runs independently of injection context
- Only parameter signals are tracked; internal signals remain untracked
- You must handle race conditions manually (no built-in switchMap)
rxMethod vs signalMethod
| Feature | rxMethod | signalMethod |
|---|---|---|
| RxJS required | Yes | No |
| Operators (debounce, switchMap) | Yes | No |
| Race condition handling | Built-in | Manual |
| Input types | T, Signal<T>, Observable<T> | T, Signal<T> |
For implementation examples, see examples/effects.md.
---
Pattern 5: Custom Features with signalStoreFeature
Use signalStoreFeature() to create reusable store functionality.
When to Use
- Shared patterns across multiple stores (loading state, error handling)
- Encapsulated feature modules
- Third-party store extensions
- DRY principle for repeated store logic
Type Constraints
Use type() helper to specify required store members:
- Ensures feature is only used with compatible stores
- Provides full type inference
- Enables composition of features
For implementation examples, see examples/features.md.
---
Pattern 6: Lifecycle Hooks with withHooks
Use withHooks() for initialization and cleanup logic.
Available Hooks
onInit()- Called when store is instantiatedonDestroy()- Called when store is destroyed
Common Use Cases
- Loading initial data on store creation
- Setting up subscriptions
- Cleanup of resources
- DevTools initialization
For implementation examples, see examples/core.md.
---
Pattern 7: Call State Pattern
Track loading, loaded, and error states for async operations using withCallState() from ngrx-toolkit or custom implementation.
States
loading- Operation in progressloaded- Operation completed successfullyerror- Operation failed with error
Benefits
- Consistent loading UI patterns
- Error handling standardization
- Multiple collection support
- DevTools visibility
For implementation examples, see examples/features.md.
---
Pattern 8: Context-Aware Features with withFeature (v20+)
Use withFeature() to create features that have access to the current store's methods and properties. Unlike signalStoreFeature(), withFeature receives the store instance, enabling reusable features that can call store-specific methods.
When to Use
- Features that need to call existing store methods
- Generic patterns like entity loaders that need store-specific fetch logic
- When
signalStoreFeature()is insufficient because the feature needs store context
import { withFeature } from "@ngrx/signals";
// withFeature receives the store instance
export const ProductStore = signalStore(
{ providedIn: "root" },
withMethods((store) => ({
load: rxMethod<string>(/* fetch logic */),
})),
withFeature((store) =>
withEntityLoader((id) => firstValueFrom(store.load(id))),
),
);---
Pattern 9: Derived Reactive State with withLinkedState (v20+)
Use withLinkedState() to create state signals that are automatically recomputed when their source signals change. Unlike withComputed(), linked state is writable and can be overridden.
When to Use
- Derived state that also needs to be independently settable
- Maintaining selection state across data changes
- State that has a default derived value but can be overridden by user action
import { withLinkedState } from "@ngrx/signals";
export const FilterStore = signalStore(
withState({ items: [] as Item[] }),
withLinkedState(({ items }) => ({
// Recomputes when items change, but can also be set independently
selectedId: () => items()[0]?.id ?? null,
})),
);</patterns>
---
<integration>
Integration Guide
Angular DI Integration:
SignalStore is an Angular service. Use { providedIn: 'root' } for singletons or component-level providers: [Store] for scoped instances that are destroyed with the component.
See examples/core.md Pattern 5 and Component-Level Stores for DI patterns.
DevTools Integration:
Use withDevtools('name') from @angular-architects/ngrx-toolkit for Redux DevTools support. Place it before withState in the feature chain.
See examples/features.md Pattern 5 for setup.
Testing Integration:
- Use
unprotected()from@ngrx/signals/testingto bypass state protection for test setup - Use
TestBed.configureTestingModule()with mocked services - Test store methods directly or through component integration
See examples/testing.md for patterns.
</integration>
---
<red_flags>
RED FLAGS
High Priority Issues:
- Mutating state directly instead of using
patchState- breaks reactivity and signal notifications - Using arrays instead of
withEntitiesfor entity collections - loses normalized state, O(1) lookups - Not wrapping async RxJS flows in
rxMethod- loses cancellation and proper cleanup - Accessing store state outside computed/template - may not trigger updates
Medium Priority Issues:
- Missing error handling in
rxMethodpipelines - Deeply nested state (flatten with multiple state slices)
- Not using
entityConfig()for custom entity IDs (v18+) - Using
signalMethodfor async operations with race conditions (userxMethodwithswitchMap)
Gotchas & Edge Cases:
withHooks.onInit()runs during store instantiation - be careful with side effects in testspatchStatewith entity updaters requirescollectionoption when using named collectionsrxMethodauto-unsubscribes on destroy - do not manually unsubscribe- Protected state (v18+) prevents external
patchStatecalls by default - Feature execution order matters - later features can access earlier ones
- v19+ Deep Freeze: State values are recursively frozen with
Object.freezein dev mode - usewithProps()for mutable objects like FormGroup
See reference.md for detailed anti-patterns with code examples.
</red_flags>
---
<critical_reminders>
CRITICAL REMINDERS
(You MUST use `patchState()` for ALL state updates - NEVER mutate state directly)
(You MUST wrap async operations in `rxMethod()` from `@ngrx/signals/rxjs-interop` for RxJS integration)
(You MUST use `withEntities()` from `@ngrx/signals/entities` for entity collections - NOT arrays in state)
(You MUST use named exports ONLY - NO default exports in any store files)
(You MUST use named constants for ALL numbers - NO magic numbers in state code)
Failure to follow these rules will cause reactivity bugs, state corruption, and inconsistent behavior.
</critical_reminders>
NgRx SignalStore - Core Examples
Core code examples for NgRx SignalStore setup with signalStore, withState, withComputed, withMethods, and withHooks.
Related examples:
- entities.md - withEntities, CRUD operations
- effects.md - rxMethod, side effects
- features.md - signalStoreFeature, custom features
- testing.md - Unit tests, mocking strategies
- migration.md - Migration from traditional NgRx
---
Pattern 1: Basic Store with signalStore
Good Example - Store with withState, withComputed, withMethods
// stores/counter.store.ts
import { computed, inject } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
const INITIAL_COUNT = 0;
const INCREMENT_STEP = 1;
const DECREMENT_STEP = 1;
interface CounterState {
count: number;
lastUpdated: Date | null;
}
export const CounterStore = signalStore(
{ providedIn: "root" },
withState<CounterState>({
count: INITIAL_COUNT,
lastUpdated: null,
}),
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
isPositive: computed(() => count() > 0),
isNegative: computed(() => count() < 0),
})),
withMethods((store) => ({
increment() {
patchState(store, (state) => ({
count: state.count + INCREMENT_STEP,
lastUpdated: new Date(),
}));
},
decrement() {
patchState(store, (state) => ({
count: state.count - DECREMENT_STEP,
lastUpdated: new Date(),
}));
},
reset() {
patchState(store, {
count: INITIAL_COUNT,
lastUpdated: new Date(),
});
},
setCount(count: number) {
patchState(store, { count, lastUpdated: new Date() });
},
})),
);Why good: Typed state interface, named constants for all numbers, providedIn for DI, computed signals for derived state, patchState for immutable updates, clear method names, all named exports
Bad Example - Direct Mutation and Magic Numbers
// WRONG - Direct mutation, magic numbers, default export
import { signalStore, withState, withMethods } from "@ngrx/signals";
const CounterStore = signalStore(
withState({ count: 0 }),
withMethods((store) => ({
increment() {
// WRONG: Magic number, should be named constant
store.count.set(store.count() + 1);
},
setCount(count: number) {
// WRONG: Direct assignment
store.count.set(count);
},
})),
);
export default CounterStore; // WRONG: default exportWhy bad: Direct signal mutation bypasses store update mechanism, magic number 1 should be named constant, default export violates conventions, no TypeScript interface for state
---
Pattern 2: Store with patchState Updaters
Good Example - Multiple patchState Patterns
// stores/user-preferences.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
const DEFAULT_THEME = "light" as const;
const DEFAULT_NOTIFICATIONS = true;
const DEFAULT_LOCALE = "en-US";
const DEFAULT_FONT_SIZE = 16;
type Theme = "light" | "dark" | "system";
interface UserPreferencesState {
theme: Theme;
notifications: boolean;
locale: string;
fontSize: number;
}
export const UserPreferencesStore = signalStore(
{ providedIn: "root" },
withState<UserPreferencesState>({
theme: DEFAULT_THEME,
notifications: DEFAULT_NOTIFICATIONS,
locale: DEFAULT_LOCALE,
fontSize: DEFAULT_FONT_SIZE,
}),
withComputed(({ theme }) => ({
isDarkMode: computed(() => theme() === "dark"),
})),
withMethods((store) => ({
// Partial state update - simple object
setTheme(theme: Theme) {
patchState(store, { theme });
},
// Partial state update - updater function
toggleNotifications() {
patchState(store, (state) => ({
notifications: !state.notifications,
}));
},
// Multiple properties at once
updateSettings(settings: Partial<UserPreferencesState>) {
patchState(store, settings);
},
// Multiple updaters in single call
resetToDefaults() {
patchState(
store,
{ theme: DEFAULT_THEME },
{ notifications: DEFAULT_NOTIFICATIONS },
{ locale: DEFAULT_LOCALE },
);
},
})),
);Why good: Multiple patchState patterns shown, updater function for computed updates, partial updates, multiple updaters in single call, all numbers are named constants
---
Pattern 3: Store with withHooks
Good Example - Lifecycle Hooks for Initialization
// stores/app-settings.store.ts
import { computed, inject } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
withHooks,
patchState,
} from "@ngrx/signals";
const STORAGE_KEY = "app-settings";
interface AppSettingsState {
initialized: boolean;
sidebarCollapsed: boolean;
lastSyncTime: Date | null;
}
export const AppSettingsStore = signalStore(
{ providedIn: "root" },
withState<AppSettingsState>({
initialized: false,
sidebarCollapsed: false,
lastSyncTime: null,
}),
withComputed(({ lastSyncTime }) => ({
formattedSyncTime: computed(
() => lastSyncTime()?.toLocaleString() ?? "Never",
),
})),
withMethods((store) => ({
toggleSidebar() {
patchState(store, (state) => ({
sidebarCollapsed: !state.sidebarCollapsed,
}));
},
loadFromStorage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<AppSettingsState>;
patchState(store, parsed);
}
patchState(store, { initialized: true });
},
saveToStorage() {
const state = {
sidebarCollapsed: store.sidebarCollapsed(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
patchState(store, { lastSyncTime: new Date() });
},
})),
withHooks({
onInit({ loadFromStorage }) {
// Load persisted settings on store creation
loadFromStorage();
},
onDestroy({ saveToStorage, sidebarCollapsed }) {
// Save settings when store is destroyed
saveToStorage();
console.log("Settings store destroyed, sidebar was:", sidebarCollapsed());
},
}),
);Why good: onInit for initialization logic, onDestroy for cleanup, hooks have access to store methods and state, persistence pattern demonstrated
---
Pattern 4: Store with Injection Context
Good Example - Injecting Services in withMethods
// stores/user.store.ts
import { computed, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import { firstValueFrom } from "rxjs";
const API_BASE_URL = "/api/users";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
interface UserState {
currentUser: User | null;
isLoading: boolean;
error: string | null;
}
export const UserStore = signalStore(
{ providedIn: "root" },
withState<UserState>({
currentUser: null,
isLoading: false,
error: null,
}),
withComputed(({ currentUser }) => ({
isLoggedIn: computed(() => currentUser() !== null),
isAdmin: computed(() => currentUser()?.role === "admin"),
displayName: computed(() => currentUser()?.name ?? "Guest"),
})),
withMethods((store) => {
// Injection context - inject services here
const http = inject(HttpClient);
return {
async loadCurrentUser() {
patchState(store, { isLoading: true, error: null });
try {
const user = await firstValueFrom(
http.get<User>(`${API_BASE_URL}/me`),
);
patchState(store, { currentUser: user ?? null, isLoading: false });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to load user";
patchState(store, { error: message, isLoading: false });
}
},
logout() {
patchState(store, {
currentUser: null,
error: null,
});
},
};
}),
);Why good: inject() used in injection context, HttpClient injected properly, async/await for simple operations, proper error handling, loading state management
---
Pattern 5: Component Usage
Good Example - Using Store in Component
// components/counter.component.ts
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { CounterStore } from "../stores/counter.store";
@Component({
selector: "app-counter",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<h2>Count: {{ store.count() }}</h2>
<p>Double: {{ store.doubleCount() }}</p>
<p>
Last updated: {{ store.lastUpdated()?.toLocaleString() ?? "Never" }}
</p>
<div class="actions">
<button (click)="store.decrement()" [disabled]="!store.isPositive()">
Decrement
</button>
<button (click)="store.increment()">Increment</button>
<button (click)="store.reset()">Reset</button>
</div>
</div>
`,
})
export class CounterComponent {
readonly store = inject(CounterStore);
}Why good: OnPush change detection (signals handle updates), inject() for DI, direct template signal access with (), readonly store reference, disabled binding uses computed signal
Bad Example - Subscribing and Manual Change Detection
// WRONG - Manual subscription, change detection
import {
Component,
inject,
OnInit,
OnDestroy,
ChangeDetectorRef,
} from "@angular/core";
import { CounterStore } from "../stores/counter.store";
import { effect } from "@angular/core";
@Component({
selector: "app-counter",
template: `<h2>Count: {{ count }}</h2>`,
})
export class CounterComponent implements OnInit, OnDestroy {
store = inject(CounterStore);
cdr = inject(ChangeDetectorRef);
count = 0;
ngOnInit() {
// WRONG: Manual subscription to signal
effect(() => {
this.count = this.store.count();
this.cdr.markForCheck(); // WRONG: Unnecessary
});
}
ngOnDestroy() {
// Cleanup logic...
}
}Why bad: Unnecessary effect subscription, manual change detection not needed with signals, stores local copy instead of using signal directly, violates signal patterns
---
Pattern 6: withProps for Non-State Properties
Good Example - Sharing Observables with withProps (v19+)
// stores/search.store.ts
import { computed, inject } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
signalStore,
withState,
withComputed,
withMethods,
withProps,
patchState,
} from "@ngrx/signals";
const DEBOUNCE_MS = 300;
interface SearchState {
query: string;
results: string[];
isSearching: boolean;
}
export const SearchStore = signalStore(
{ providedIn: "root" },
withState<SearchState>({
query: "",
results: [],
isSearching: false,
}),
withComputed(({ query, results }) => ({
hasResults: computed(() => results().length > 0),
resultCount: computed(() => results().length),
})),
// withProps for non-reactive properties (observables, constants)
withProps(({ query }) => ({
// Expose query as observable for RxJS interop
query$: toObservable(query),
debounceMs: DEBOUNCE_MS,
})),
withMethods((store) => ({
setQuery(query: string) {
patchState(store, { query });
},
setResults(results: string[]) {
patchState(store, { results, isSearching: false });
},
startSearch() {
patchState(store, { isSearching: true });
},
})),
);Why good: withProps for non-signal properties, toObservable bridges signals to RxJS, constants exposed as props, clear separation of concerns
---
Component-Level Stores
Good Example - Scoped Store with Component Lifecycle
// stores/modal.store.ts
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
interface ModalState {
isOpen: boolean;
title: string;
content: string;
}
// NOT providedIn: 'root' - component will provide
export const ModalStore = signalStore(
withState<ModalState>({
isOpen: false,
title: "",
content: "",
}),
withMethods((store) => ({
open(title: string, content: string) {
patchState(store, { isOpen: true, title, content });
},
close() {
patchState(store, { isOpen: false });
},
})),
);
// components/modal-container.component.ts
import { Component, inject } from "@angular/core";
import { ModalStore } from "../stores/modal.store";
@Component({
selector: "app-modal-container",
standalone: true,
// Component-level provider - new instance per component
providers: [ModalStore],
template: `
@if (store.isOpen()) {
<div class="modal-backdrop" (click)="store.close()">
<div class="modal" (click)="$event.stopPropagation()">
<h2>{{ store.title() }}</h2>
<p>{{ store.content() }}</p>
<button (click)="store.close()">Close</button>
</div>
</div>
}
`,
})
export class ModalContainerComponent {
readonly store = inject(ModalStore);
}Why good: Component-scoped store (no providedIn), providers array for DI, store destroyed with component, each instance independent
---
Pattern 7: withProps for Mutable Objects (v19+ Deep Freeze Migration)
Good Example - FormGroup with withProps (v19+ Breaking Change)
In v19+, state values are recursively frozen with Object.freeze. Use withProps() for mutable objects like Angular's FormGroup.
// stores/form.store.ts - v19+ CORRECT approach
import { inject } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import {
signalStore,
withState,
withMethods,
withProps,
patchState,
} from "@ngrx/signals";
interface UserFormData {
name: string;
email: string;
}
interface FormState {
isSubmitting: boolean;
submitError: string | null;
lastSavedData: UserFormData | null;
}
export const UserFormStore = signalStore(
{ providedIn: "root" },
// Reactive state (will be frozen)
withState<FormState>({
isSubmitting: false,
submitError: null,
lastSavedData: null,
}),
// Mutable objects go in withProps (NOT frozen)
withProps(() => {
const fb = inject(FormBuilder);
return {
// FormGroup is mutable - use withProps
formGroup: fb.group({
name: ["", [Validators.required, Validators.minLength(2)]],
email: ["", [Validators.required, Validators.email]],
}),
};
}),
withMethods((store) => ({
async submit() {
if (store.formGroup.invalid) return;
patchState(store, { isSubmitting: true, submitError: null });
try {
const data = store.formGroup.value as UserFormData;
await fetch("/api/users", {
method: "POST",
body: JSON.stringify(data),
});
patchState(store, {
isSubmitting: false,
lastSavedData: data,
});
store.formGroup.reset();
} catch (err) {
patchState(store, {
isSubmitting: false,
submitError: err instanceof Error ? err.message : "Submit failed",
});
}
},
resetForm() {
store.formGroup.reset();
patchState(store, { submitError: null });
},
loadData(data: UserFormData) {
store.formGroup.patchValue(data);
},
})),
);Why good: FormGroup in withProps (mutable, not frozen), reactive state in withState (immutable), clear separation of mutable vs immutable data
Bad Example - FormGroup in withState (v19+ Breaking Change)
// WRONG - FormGroup in withState will be frozen in v19+
import { FormGroup, FormControl } from "@angular/forms";
import { signalStore, withState, withMethods } from "@ngrx/signals";
export const BrokenFormStore = signalStore(
// ERROR in v19+: "object is not extensible"
withState({
formGroup: new FormGroup({
name: new FormControl(""),
}),
}),
withMethods((store) => ({
addField() {
// This will FAIL in v19+ due to deep freeze
store.formGroup().addControl("email", new FormControl(""));
},
})),
);Why bad: v19+ applies Object.freeze recursively to state - FormGroup mutations will throw "object is not extensible" error
withState vs withProps Decision Guide
| Use Case | Feature | Reason |
|---|---|---|
| Primitive values | withState | Reactive, immutable |
| Simple objects | withState | Reactive, frozen |
| Arrays | withState | Reactive, use entity updaters |
| FormGroup/FormControl | withProps | Needs mutation |
| Services | withProps | Not state |
| Observables | withProps | Not reactive state |
| Constants | withProps | Static values |
NgRx SignalStore - Effects Examples
Side effect patterns using rxMethod for RxJS integration and async operations.
Prerequisites: Understand core.md (signalStore basics) first.
Related examples:
- entities.md - withEntities, CRUD operations
- features.md - signalStoreFeature, custom features
- testing.md - Unit tests, mocking strategies
---
Pattern 1: Basic rxMethod for HTTP Requests
Good Example - rxMethod with switchMap
// stores/search.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
catchError,
of,
debounceTime,
distinctUntilChanged,
} from "rxjs";
const SEARCH_API_URL = "/api/search";
const DEBOUNCE_MS = 300;
interface SearchResult {
id: string;
title: string;
description: string;
}
interface SearchState {
query: string;
results: SearchResult[];
isLoading: boolean;
error: string | null;
}
export const SearchStore = signalStore(
{ providedIn: "root" },
withState<SearchState>({
query: "",
results: [],
isLoading: false,
error: null,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
// rxMethod for reactive search with debounce
search: rxMethod<string>(
pipe(
tap((query) => {
patchState(store, { query, isLoading: true, error: null });
}),
debounceTime(DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
if (!query.trim()) {
return of([]);
}
return http
.get<
SearchResult[]
>(`${SEARCH_API_URL}?q=${encodeURIComponent(query)}`)
.pipe(
catchError((err) => {
patchState(store, {
error: err.message ?? "Search failed",
isLoading: false,
});
return of([]);
}),
);
}),
tap((results) => {
patchState(store, { results, isLoading: false });
}),
),
),
clearSearch() {
patchState(store, {
query: "",
results: [],
error: null,
});
},
};
}),
);Why good: rxMethod for reactive search, debounceTime prevents excessive API calls, switchMap cancels pending requests, error handling with catchError, named constants
Bad Example - async/await Without Cancellation
// WRONG - No cancellation, no debounce
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { firstValueFrom } from "rxjs";
export const SearchStore = signalStore(
withState({
query: "",
results: [] as any[],
isLoading: false,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
// WRONG: No debounce, no cancellation
async search(query: string) {
patchState(store, { query, isLoading: true });
// WRONG: Each keystroke triggers a request
// Previous requests not cancelled
const results = await firstValueFrom(
http.get<any[]>(`/api/search?q=${query}`),
);
patchState(store, { results, isLoading: false });
},
};
}),
);Why bad: No debounce causes excessive API calls, async/await doesn't cancel previous requests, race conditions when responses arrive out of order
---
Pattern 2: rxMethod with Signal Input
Good Example - Connecting Signal to rxMethod
// stores/flight.store.ts
import { computed, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {
signalStore,
withState,
withComputed,
withMethods,
withHooks,
patchState,
} from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
filter,
debounceTime,
catchError,
of,
} from "rxjs";
const FLIGHTS_API_URL = "/api/flights";
const MIN_QUERY_LENGTH = 3;
const SEARCH_DEBOUNCE_MS = 300;
interface Flight {
id: string;
from: string;
to: string;
date: string;
price: number;
}
interface Criteria {
from: string;
to: string;
}
interface FlightState {
from: string;
to: string;
flights: Flight[];
isLoading: boolean;
error: string | null;
}
export const FlightStore = signalStore(
{ providedIn: "root" },
withState<FlightState>({
from: "",
to: "",
flights: [],
isLoading: false,
error: null,
}),
withComputed(({ from, to }) => ({
criteria: computed<Criteria>(() => ({ from: from(), to: to() })),
isValidCriteria: computed(
() =>
from().length >= MIN_QUERY_LENGTH && to().length >= MIN_QUERY_LENGTH,
),
})),
withMethods((store) => {
const http = inject(HttpClient);
return {
updateFrom(from: string) {
patchState(store, { from });
},
updateTo(to: string) {
patchState(store, { to });
},
// rxMethod accepts Observable, Signal, or static value
loadFlights: rxMethod<Criteria>(
pipe(
filter(
(c) =>
c.from.length >= MIN_QUERY_LENGTH &&
c.to.length >= MIN_QUERY_LENGTH,
),
tap(() => patchState(store, { isLoading: true, error: null })),
debounceTime(SEARCH_DEBOUNCE_MS),
switchMap((criteria) =>
http
.get<Flight[]>(FLIGHTS_API_URL, {
params: { from: criteria.from, to: criteria.to },
})
.pipe(
catchError((err) => {
patchState(store, {
error: err.message ?? "Failed to load flights",
isLoading: false,
});
return of([]);
}),
),
),
tap((flights) => {
patchState(store, { flights, isLoading: false });
}),
),
),
};
}),
withHooks({
onInit({ loadFlights, criteria }) {
// Connect computed signal to rxMethod
// Automatically triggers when criteria changes
loadFlights(criteria);
},
}),
);Why good: rxMethod accepts Signal input, auto-triggers on signal change, filter prevents invalid searches, criteria computed for reactive composition, onInit connects signal to effect
---
Pattern 3: rxMethod with Retry and Error Recovery
Good Example - Retry Logic with exponentialBackoff
// stores/data-sync.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
retry,
catchError,
of,
timer,
delayWhen,
} from "rxjs";
const SYNC_API_URL = "/api/sync";
const MAX_RETRY_ATTEMPTS = 3;
const INITIAL_RETRY_DELAY_MS = 1000;
interface SyncState {
lastSyncTime: Date | null;
isSyncing: boolean;
syncError: string | null;
retryCount: number;
}
export const DataSyncStore = signalStore(
{ providedIn: "root" },
withState<SyncState>({
lastSyncTime: null,
isSyncing: false,
syncError: null,
retryCount: 0,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
sync: rxMethod<void>(
pipe(
tap(() => {
patchState(store, {
isSyncing: true,
syncError: null,
retryCount: 0,
});
}),
switchMap(() =>
http.post<{ timestamp: string }>(SYNC_API_URL, {}).pipe(
// Retry with exponential backoff
retry({
count: MAX_RETRY_ATTEMPTS,
delay: (error, retryCount) => {
patchState(store, { retryCount });
const delayMs =
INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1);
console.log(
`Retry ${retryCount}/${MAX_RETRY_ATTEMPTS} in ${delayMs}ms`,
);
return timer(delayMs);
},
}),
catchError((err) => {
patchState(store, {
syncError: `Sync failed after ${MAX_RETRY_ATTEMPTS} attempts: ${err.message}`,
isSyncing: false,
});
return of(null);
}),
),
),
tap((result) => {
if (result) {
patchState(store, {
lastSyncTime: new Date(result.timestamp),
isSyncing: false,
retryCount: 0,
});
}
}),
),
),
clearError() {
patchState(store, { syncError: null });
},
};
}),
);Why good: Retry with exponential backoff, retry count tracked in state, proper error handling, timer for delay, named constants for configuration
---
Pattern 4: Multiple rxMethods with Coordination
Good Example - Coordinated Effects
// stores/dashboard.store.ts
import { computed, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
forkJoin,
catchError,
of,
merge,
map,
interval,
} from "rxjs";
const API_BASE = "/api/dashboard";
const REFRESH_INTERVAL_MS = 30000;
interface Stats {
users: number;
orders: number;
revenue: number;
}
interface Activity {
id: string;
type: string;
message: string;
timestamp: Date;
}
interface DashboardState {
stats: Stats | null;
recentActivity: Activity[];
isLoadingStats: boolean;
isLoadingActivity: boolean;
error: string | null;
}
export const DashboardStore = signalStore(
{ providedIn: "root" },
withState<DashboardState>({
stats: null,
recentActivity: [],
isLoadingStats: false,
isLoadingActivity: false,
error: null,
}),
withComputed(({ isLoadingStats, isLoadingActivity }) => ({
isLoading: computed(() => isLoadingStats() || isLoadingActivity()),
})),
withMethods((store) => {
const http = inject(HttpClient);
return {
// Load stats
loadStats: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoadingStats: true, error: null })),
switchMap(() =>
http.get<Stats>(`${API_BASE}/stats`).pipe(
catchError((err) => {
patchState(store, {
error: err.message,
isLoadingStats: false,
});
return of(null);
}),
),
),
tap((stats) => {
if (stats) {
patchState(store, { stats, isLoadingStats: false });
}
}),
),
),
// Load activity
loadActivity: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoadingActivity: true })),
switchMap(() =>
http.get<Activity[]>(`${API_BASE}/activity`).pipe(
catchError((err) => {
patchState(store, {
error: err.message,
isLoadingActivity: false,
});
return of([]);
}),
),
),
tap((activity) => {
patchState(store, {
recentActivity: activity,
isLoadingActivity: false,
});
}),
),
),
// Parallel load - triggers both effects
loadAll() {
this.loadStats();
this.loadActivity();
},
// Auto-refresh with interval
startAutoRefresh: rxMethod<void>(
pipe(
switchMap(() =>
interval(REFRESH_INTERVAL_MS).pipe(
tap(() => {
this.loadStats();
this.loadActivity();
}),
),
),
),
),
};
}),
);Why good: Multiple independent rxMethods, coordinated via wrapper method, auto-refresh with interval, separate loading states, computed for combined loading state
---
Pattern 5: rxMethod with Polling and Cancellation
Good Example - Polling with Stop Capability
// stores/notifications.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
interval,
takeUntil,
Subject,
startWith,
catchError,
of,
} from "rxjs";
const NOTIFICATIONS_API_URL = "/api/notifications";
const POLL_INTERVAL_MS = 10000;
interface Notification {
id: string;
message: string;
read: boolean;
}
interface NotificationState {
notifications: Notification[];
isPolling: boolean;
lastPollTime: Date | null;
}
export const NotificationStore = signalStore(
{ providedIn: "root" },
withState<NotificationState>({
notifications: [],
isPolling: false,
lastPollTime: null,
}),
withMethods((store) => {
const http = inject(HttpClient);
// Subject for stopping polling
const stopPolling$ = new Subject<void>();
return {
// Start polling
startPolling: rxMethod<void>(
pipe(
tap(() => patchState(store, { isPolling: true })),
switchMap(() =>
interval(POLL_INTERVAL_MS).pipe(
startWith(0), // Immediate first fetch
takeUntil(stopPolling$),
switchMap(() =>
http
.get<Notification[]>(NOTIFICATIONS_API_URL)
.pipe(catchError(() => of([]))),
),
tap((notifications) => {
patchState(store, {
notifications,
lastPollTime: new Date(),
});
}),
),
),
tap({
complete: () => patchState(store, { isPolling: false }),
}),
),
),
stopPolling() {
stopPolling$.next();
patchState(store, { isPolling: false });
},
markAsRead(id: string) {
patchState(store, (state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n,
),
}));
},
};
}),
);Why good: Polling with interval, takeUntil for cancellation, Subject for stop signal, startWith for immediate first fetch, proper cleanup
---
Pattern 6: rxMethod Input Types
Good Example - Different Input Types
// stores/api.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signal } from "@angular/core";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, tap, switchMap, of } from "rxjs";
interface ApiState {
data: unknown | null;
isLoading: boolean;
}
export const ApiStore = signalStore(
{ providedIn: "root" },
withState<ApiState>({
data: null,
isLoading: false,
}),
withMethods((store) => {
const http = inject(HttpClient);
// rxMethod accepts T, Signal<T>, or Observable<T>
const fetchData = rxMethod<string>(
pipe(
tap(() => patchState(store, { isLoading: true })),
switchMap((url) => http.get(url)),
tap((data) => patchState(store, { data, isLoading: false })),
),
);
return {
fetchData,
// Example usages:
// 1. Static value
fetchUsers() {
fetchData("/api/users");
},
// 2. Signal value
fetchFromSignal() {
const url = signal("/api/products");
fetchData(url); // Reacts to signal changes
},
// 3. Observable value
fetchFromObservable() {
const url$ = of("/api/orders");
fetchData(url$);
},
};
}),
);Why good: Demonstrates rxMethod flexibility - accepts static values, signals, or observables as input
---
Pattern 7: signalMethod for Signal-Only Side Effects (v19+)
Good Example - Using signalMethod Without RxJS
// stores/theme.store.ts
import { effect, signal } from "@angular/core";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { signalMethod } from "@ngrx/signals";
type Theme = "light" | "dark" | "system";
const STORAGE_KEY = "app-theme";
interface ThemeState {
theme: Theme;
resolvedTheme: "light" | "dark";
}
export const ThemeStore = signalStore(
{ providedIn: "root" },
withState<ThemeState>({
theme: "system",
resolvedTheme: "light",
}),
withMethods((store) => ({
// signalMethod for simple side effects (v19+)
// No RxJS required, works with signals or static values
applyTheme: signalMethod<Theme>((theme) => {
// Side effect: update DOM and localStorage
const resolved =
theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: theme;
document.documentElement.setAttribute("data-theme", resolved);
localStorage.setItem(STORAGE_KEY, theme);
// Update store state
patchState(store, { theme, resolvedTheme: resolved });
}),
// Can call with static value
setDarkMode() {
this.applyTheme("dark");
},
// Or connect to a signal for reactive updates
connectToSystemTheme() {
const systemTheme = signal<Theme>("system");
// Automatically re-runs when systemTheme changes
this.applyTheme(systemTheme);
},
loadSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (saved) {
this.applyTheme(saved);
}
},
})),
);Why good: signalMethod for simple side effects without RxJS, works with both static values and signals, STORAGE_KEY named constant
When to Use signalMethod vs rxMethod
// stores/comparison.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { signalMethod } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, switchMap, debounceTime, tap } from "rxjs";
const DEBOUNCE_MS = 300;
export const ComparisonStore = signalStore(
{ providedIn: "root" },
withState({
localData: "",
searchResults: [] as string[],
isSearching: false,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
// USE signalMethod: Simple side effect, no async, no RxJS operators
updateLocalData: signalMethod<string>((data) => {
// Simple sync update + side effect
console.log("Data updated:", data);
patchState(store, { localData: data });
}),
// USE rxMethod: Need debounce, cancellation, RxJS operators
search: rxMethod<string>(
pipe(
tap(() => patchState(store, { isSearching: true })),
debounceTime(DEBOUNCE_MS),
switchMap((query) => http.get<string[]>(`/api/search?q=${query}`)),
tap((results) =>
patchState(store, { searchResults: results, isSearching: false }),
),
),
),
};
}),
);Why good: Shows clear distinction - signalMethod for simple sync side effects, rxMethod when you need RxJS operators like debounce/switchMap
Important: Race Conditions with signalMethod
// CAUTION: signalMethod does NOT handle race conditions
// stores/unsafe-search.store.ts
// BAD: Using signalMethod for async operations
const unsafeSearch = signalMethod<string>(async (query) => {
patchState(store, { isLoading: true });
// Race condition! Multiple calls can resolve out of order
const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
patchState(store, { results, isLoading: false }); // May overwrite newer results
});
// GOOD: Use rxMethod with switchMap for async operations
const safeSearch = rxMethod<string>(
pipe(
tap(() => patchState(store, { isLoading: true })),
switchMap((query) => http.get(`/api/search?q=${query}`)),
tap((results) => patchState(store, { results, isLoading: false })),
),
);Why important: signalMethod does not cancel previous operations - use rxMethod with switchMap for cancellable async requests
NgRx SignalStore - Entity Examples
Entity management examples using withEntities for collections with CRUD operations.
Prerequisites: Understand core.md (signalStore basics) first.
Related examples:
- effects.md - rxMethod, side effects
- features.md - signalStoreFeature, custom features
- testing.md - Unit tests, mocking strategies
---
Pattern 1: Basic Entity Store
Good Example - withEntities for Todo Collection
// stores/todo.store.ts
import { computed, inject } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
setAllEntities,
addEntity,
updateEntity,
removeEntity,
setEntity,
} from "@ngrx/signals/entities";
interface Todo {
id: string;
title: string;
completed: boolean;
priority: "low" | "medium" | "high";
createdAt: Date;
}
interface TodoMeta {
filter: "all" | "active" | "completed";
isLoading: boolean;
}
export const TodoStore = signalStore(
{ providedIn: "root" },
// withEntities creates: ids, entityMap, entities (signal array)
withEntities<Todo>(),
// Additional state alongside entities
withState<TodoMeta>({
filter: "all",
isLoading: false,
}),
withComputed(({ entities, filter }) => ({
filteredTodos: computed(() => {
const todos = entities();
const currentFilter = filter();
switch (currentFilter) {
case "active":
return todos.filter((t) => !t.completed);
case "completed":
return todos.filter((t) => t.completed);
default:
return todos;
}
}),
completedCount: computed(
() => entities().filter((t) => t.completed).length,
),
activeCount: computed(() => entities().filter((t) => !t.completed).length),
totalCount: computed(() => entities().length),
})),
withMethods((store) => ({
// Set all entities (replace entire collection)
setTodos(todos: Todo[]) {
patchState(store, setAllEntities(todos));
},
// Add single entity
addTodo(title: string, priority: Todo["priority"] = "medium") {
const newTodo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
priority,
createdAt: new Date(),
};
patchState(store, addEntity(newTodo));
},
// Update single entity
toggleTodo(id: string) {
patchState(
store,
updateEntity({
id,
changes: (todo) => ({ completed: !todo.completed }),
}),
);
},
// Update with partial object
updateTodoPriority(id: string, priority: Todo["priority"]) {
patchState(store, updateEntity({ id, changes: { priority } }));
},
// Remove entity
removeTodo(id: string) {
patchState(store, removeEntity(id));
},
// Upsert - add or update
upsertTodo(todo: Todo) {
patchState(store, setEntity(todo));
},
// Set filter
setFilter(filter: TodoMeta["filter"]) {
patchState(store, { filter });
},
// Clear completed
clearCompleted() {
const completedIds = store
.entities()
.filter((t) => t.completed)
.map((t) => t.id);
completedIds.forEach((id) => {
patchState(store, removeEntity(id));
});
},
})),
);Why good: withEntities provides normalized state, entity updaters (setAllEntities, addEntity, etc.), computed for filtered views, additional state with withState, type-safe entity operations
Bad Example - Array Instead of withEntities
// WRONG - Using array for entities
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
interface Todo {
id: string;
title: string;
completed: boolean;
}
export const TodoStore = signalStore(
// WRONG: Array in state - should use withEntities
withState({
todos: [] as Todo[],
filter: "all" as "all" | "active" | "completed",
}),
withMethods((store) => ({
// WRONG: O(n) lookups, manual spread operators
toggleTodo(id: string) {
patchState(store, (state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t,
),
}));
},
removeTodo(id: string) {
// WRONG: Filter creates new array every time
patchState(store, (state) => ({
todos: state.todos.filter((t) => t.id !== id),
}));
},
})),
);Why bad: No normalized state structure (no entityMap), O(n) lookups for updates, manual immutable operations, loses withEntities benefits like selectId
---
Pattern 2: Named Entity Collections
Good Example - Multiple Collections with Named Entities
// stores/project.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
type,
} from "@ngrx/signals";
import {
withEntities,
setAllEntities,
addEntity,
updateEntity,
removeEntity,
} from "@ngrx/signals/entities";
interface Task {
id: string;
title: string;
projectId: string;
status: "todo" | "in-progress" | "done";
}
interface Project {
id: string;
name: string;
color: string;
}
export const ProjectStore = signalStore(
{ providedIn: "root" },
// Multiple named collections using 'collection' option
withEntities({ entity: type<Project>(), collection: "project" }),
withEntities({ entity: type<Task>(), collection: "task" }),
withState({
selectedProjectId: null as string | null,
isLoading: false,
}),
withComputed((store) => ({
// Named collections create prefixed signals
// projectEntities, projectIds, projectEntityMap
// taskEntities, taskIds, taskEntityMap
selectedProject: computed(() => {
const id = store.selectedProjectId();
return id ? (store.projectEntityMap()[id] ?? null) : null;
}),
tasksForSelectedProject: computed(() => {
const projectId = store.selectedProjectId();
if (!projectId) return [];
return store.taskEntities().filter((t) => t.projectId === projectId);
}),
taskCountByProject: computed(() => {
const counts = new Map<string, number>();
store.taskEntities().forEach((task) => {
const current = counts.get(task.projectId) ?? 0;
counts.set(task.projectId, current + 1);
});
return counts;
}),
})),
withMethods((store) => ({
// Project operations - use { collection: 'project' }
setProjects(projects: Project[]) {
patchState(store, setAllEntities(projects, { collection: "project" }));
},
addProject(name: string, color: string) {
const project: Project = {
id: crypto.randomUUID(),
name,
color,
};
patchState(store, addEntity(project, { collection: "project" }));
},
removeProject(id: string) {
// Remove project and all its tasks
const tasksToRemove = store
.taskEntities()
.filter((t) => t.projectId === id)
.map((t) => t.id);
tasksToRemove.forEach((taskId) => {
patchState(store, removeEntity(taskId, { collection: "task" }));
});
patchState(store, removeEntity(id, { collection: "project" }));
},
// Task operations - use { collection: 'task' }
setTasks(tasks: Task[]) {
patchState(store, setAllEntities(tasks, { collection: "task" }));
},
addTask(projectId: string, title: string) {
const task: Task = {
id: crypto.randomUUID(),
title,
projectId,
status: "todo",
};
patchState(store, addEntity(task, { collection: "task" }));
},
updateTaskStatus(id: string, status: Task["status"]) {
patchState(
store,
updateEntity({ id, changes: { status } }, { collection: "task" }),
);
},
selectProject(projectId: string | null) {
patchState(store, { selectedProjectId: projectId });
},
})),
);Why good: Multiple entity collections in one store, named collections with collection option, computed signals for cross-collection queries, cascade delete pattern
---
Pattern 3: Custom Entity ID
Good Example - entityConfig for Non-Standard IDs (v18+)
// stores/user.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
setAllEntities,
updateEntity,
entityConfig,
} from "@ngrx/signals/entities";
interface User {
odataId: string; // Non-standard ID field
email: string;
displayName: string;
isActive: boolean;
}
// entityConfig for custom ID selector (v18+)
const userConfig = entityConfig({
entity: {} as User,
selectId: (user) => user.odataId, // Custom ID selector
});
export const UserStore = signalStore(
{ providedIn: "root" },
withEntities(userConfig),
withComputed(({ entities }) => ({
activeUsers: computed(() => entities().filter((u) => u.isActive)),
inactiveUsers: computed(() => entities().filter((u) => !u.isActive)),
userCount: computed(() => entities().length),
})),
withMethods((store) => ({
setUsers(users: User[]) {
patchState(store, setAllEntities(users, userConfig));
},
toggleUserActive(odataId: string) {
patchState(
store,
updateEntity(
{
id: odataId, // Uses custom ID
changes: (user) => ({ isActive: !user.isActive }),
},
userConfig,
),
);
},
updateUserEmail(odataId: string, email: string) {
patchState(
store,
updateEntity({ id: odataId, changes: { email } }, userConfig),
);
},
})),
);Why good: entityConfig for custom ID field, selectId function extracts non-standard ID, consistent config passed to all entity operations
---
Pattern 4: Entity with Sorting
Good Example - Sorted Entity Collection
// stores/notification.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
addEntity,
removeEntity,
updateAllEntities,
} from "@ngrx/signals/entities";
const MAX_NOTIFICATIONS = 100;
interface Notification {
id: string;
message: string;
type: "info" | "warning" | "error";
timestamp: Date;
read: boolean;
}
export const NotificationStore = signalStore(
{ providedIn: "root" },
withEntities<Notification>(),
withComputed(({ entities }) => ({
// Sort in computed for consistent order
sortedNotifications: computed(() =>
[...entities()].sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime(),
),
),
unreadCount: computed(() => entities().filter((n) => !n.read).length),
hasUnread: computed(() => entities().some((n) => !n.read)),
errorNotifications: computed(() =>
entities().filter((n) => n.type === "error" && !n.read),
),
})),
withMethods((store) => ({
addNotification(message: string, type: Notification["type"] = "info") {
const notification: Notification = {
id: crypto.randomUUID(),
message,
type,
timestamp: new Date(),
read: false,
};
patchState(store, addEntity(notification));
// Trim old notifications if over limit
const allNotifications = store.entities();
if (allNotifications.length > MAX_NOTIFICATIONS) {
const sorted = [...allNotifications].sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
const toRemove = sorted.slice(
0,
allNotifications.length - MAX_NOTIFICATIONS,
);
toRemove.forEach((n) => {
patchState(store, removeEntity(n.id));
});
}
},
markAsRead(id: string) {
patchState(store, updateEntity({ id, changes: { read: true } }));
},
markAllAsRead() {
patchState(store, updateAllEntities({ read: true }));
},
removeNotification(id: string) {
patchState(store, removeEntity(id));
},
clearAll() {
const ids = store.ids();
ids.forEach((id) => {
patchState(store, removeEntity(id));
});
},
})),
);Why good: Sorting in computed (not stored), MAX_NOTIFICATIONS named constant, updateAllEntities for batch updates, efficient entity operations
---
Pattern 5: Bulk Entity Operations
Good Example - addEntities, updateEntities, removeEntities
// stores/inventory.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
setAllEntities,
addEntities,
updateEntities,
removeEntities,
setEntities,
} from "@ngrx/signals/entities";
const LOW_STOCK_THRESHOLD = 10;
interface Product {
id: string;
name: string;
sku: string;
quantity: number;
price: number;
category: string;
}
export const InventoryStore = signalStore(
{ providedIn: "root" },
withEntities<Product>(),
withState({
selectedCategory: null as string | null,
isLoading: false,
}),
withComputed(({ entities, selectedCategory }) => ({
lowStockProducts: computed(() =>
entities().filter((p) => p.quantity < LOW_STOCK_THRESHOLD),
),
productsByCategory: computed(() => {
const category = selectedCategory();
if (!category) return entities();
return entities().filter((p) => p.category === category);
}),
totalInventoryValue: computed(() =>
entities().reduce((sum, p) => sum + p.quantity * p.price, 0),
),
categories: computed(() => [...new Set(entities().map((p) => p.category))]),
})),
withMethods((store) => ({
// Bulk set (replace all)
setProducts(products: Product[]) {
patchState(store, setAllEntities(products));
},
// Bulk add
addProducts(products: Product[]) {
patchState(store, addEntities(products));
},
// Bulk upsert
upsertProducts(products: Product[]) {
patchState(store, setEntities(products));
},
// Bulk update by predicate
markLowStockProducts() {
const lowStockIds = store
.entities()
.filter((p) => p.quantity < LOW_STOCK_THRESHOLD)
.map((p) => p.id);
// Update specific entities
patchState(
store,
updateEntities({
ids: lowStockIds,
changes: { category: "low-stock" },
}),
);
},
// Update all entities matching predicate
applyDiscount(category: string, discountPercent: number) {
const discountMultiplier = 1 - discountPercent / 100;
const idsInCategory = store
.entities()
.filter((p) => p.category === category)
.map((p) => p.id);
idsInCategory.forEach((id) => {
patchState(
store,
updateEntity({
id,
changes: (product) => ({
price: Math.round(product.price * discountMultiplier * 100) / 100,
}),
}),
);
});
},
// Bulk remove
removeProducts(ids: string[]) {
patchState(store, removeEntities(ids));
},
// Remove by predicate
removeOutOfStock() {
const outOfStockIds = store
.entities()
.filter((p) => p.quantity === 0)
.map((p) => p.id);
patchState(store, removeEntities(outOfStockIds));
},
setSelectedCategory(category: string | null) {
patchState(store, { selectedCategory: category });
},
})),
);Why good: Bulk operations (addEntities, updateEntities, removeEntities), computed for filtering, predicate-based updates, named constant for threshold
---
Pattern 6: prependEntity and upsertEntity (v20+)
Good Example - New Entity Operations
// stores/activity-feed.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
prependEntity,
upsertEntity,
setAllEntities,
} from "@ngrx/signals/entities";
const MAX_FEED_ITEMS = 50;
interface ActivityItem {
id: string;
type: "message" | "notification" | "update";
content: string;
timestamp: Date;
read: boolean;
}
export const ActivityFeedStore = signalStore(
{ providedIn: "root" },
withEntities<ActivityItem>(),
withComputed(({ entities }) => ({
unreadCount: computed(() => entities().filter((a) => !a.read).length),
recentItems: computed(() => entities().slice(0, 10)),
})),
withMethods((store) => ({
// prependEntity adds to START of collection (v20+)
// Useful for feeds, logs, chat where newest items appear first
addNewActivity(type: ActivityItem["type"], content: string) {
const item: ActivityItem = {
id: crypto.randomUUID(),
type,
content,
timestamp: new Date(),
read: false,
};
// Item appears at the beginning of entities()
patchState(store, prependEntity(item));
// Trim old items if over limit
const allItems = store.entities();
if (allItems.length > MAX_FEED_ITEMS) {
const trimmed = allItems.slice(0, MAX_FEED_ITEMS);
patchState(store, setAllEntities(trimmed));
}
},
// upsertEntity merges if exists, adds if not (v20+)
// Only updates provided properties - efficient for partial updates
updateOrAddActivity(item: Partial<ActivityItem> & { id: string }) {
// If entity with id exists: merges provided props
// If not exists: adds as new entity
patchState(store, upsertEntity(item));
},
// Practical use case: mark as read with upsert
markAsRead(id: string) {
// Only updates `read` property, preserves everything else
patchState(store, upsertEntity({ id, read: true }));
},
// Batch upsert for sync scenarios
syncActivities(activities: ActivityItem[]) {
activities.forEach((activity) => {
patchState(store, upsertEntity(activity));
});
},
})),
);Why good: prependEntity adds to start (ideal for feeds/logs), upsertEntity handles add-or-update logic automatically, partial updates preserve existing data, MAX_FEED_ITEMS named constant
NgRx SignalStore - Custom Features Examples
Custom feature patterns using signalStoreFeature for reusable store logic.
Prerequisites: Understand core.md (signalStore basics) first.
Related examples:
- entities.md - withEntities, CRUD operations
- effects.md - rxMethod, side effects
- testing.md - Unit tests, mocking strategies
---
Pattern 1: Basic signalStoreFeature
Good Example - Reusable Loading State Feature
// features/with-loading.ts
import { computed } from "@angular/core";
import {
signalStoreFeature,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
type LoadingStatus = "idle" | "loading" | "loaded" | "error";
interface LoadingState {
status: LoadingStatus;
error: string | null;
}
export function withLoading() {
return signalStoreFeature(
withState<LoadingState>({
status: "idle",
error: null,
}),
withComputed(({ status }) => ({
isLoading: computed(() => status() === "loading"),
isLoaded: computed(() => status() === "loaded"),
hasError: computed(() => status() === "error"),
isIdle: computed(() => status() === "idle"),
})),
withMethods((store) => ({
setLoading() {
patchState(store, { status: "loading", error: null });
},
setLoaded() {
patchState(store, { status: "loaded", error: null });
},
setError(error: string) {
patchState(store, { status: "error", error });
},
resetStatus() {
patchState(store, { status: "idle", error: null });
},
})),
);
}
// Usage in store
// stores/user.store.ts
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { withLoading } from "../features/with-loading";
interface User {
id: string;
name: string;
}
export const UserStore = signalStore(
{ providedIn: "root" },
withLoading(),
withState({
user: null as User | null,
}),
withMethods((store) => ({
async loadUser(id: string) {
store.setLoading();
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
patchState(store, { user });
store.setLoaded();
} catch (err) {
store.setError(err instanceof Error ? err.message : "Failed to load");
}
},
})),
);Why good: Reusable feature, encapsulated loading logic, computed signals for status checks, methods for state transitions, clean usage in stores
---
Pattern 2: withCallState from ngrx-toolkit
Good Example - Using ngrx-toolkit withCallState
// stores/product.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { withEntities, setAllEntities } from "@ngrx/signals/entities";
import {
withCallState,
setLoading,
setLoaded,
setError,
} from "@angular-architects/ngrx-toolkit";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, tap, switchMap, catchError, of } from "rxjs";
const PRODUCTS_API_URL = "/api/products";
interface Product {
id: string;
name: string;
price: number;
category: string;
}
export const ProductStore = signalStore(
{ providedIn: "root" },
// withCallState adds: callState, loading, loaded, error
withCallState(),
withEntities<Product>(),
withState({
selectedCategory: null as string | null,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
loadProducts: rxMethod<void>(
pipe(
tap(() => patchState(store, setLoading())),
switchMap(() =>
http.get<Product[]>(PRODUCTS_API_URL).pipe(
catchError((err) => {
patchState(store, setError(err));
return of([]);
}),
),
),
tap((products) => {
patchState(store, setAllEntities(products), setLoaded());
}),
),
),
selectCategory(category: string | null) {
patchState(store, { selectedCategory: category });
},
};
}),
);
// Template usage
@Component({
template: `
@if (store.loading()) {
<app-spinner />
}
@if (store.error(); as error) {
<app-error [message]="error.message" />
}
@if (store.loaded()) {
<app-product-list [products]="store.entities()" />
}
`,
})
export class ProductListComponent {
readonly store = inject(ProductStore);
}Why good: ngrx-toolkit withCallState provides standardized loading/error handling, setLoading/setLoaded/setError updaters, template-friendly computed signals
---
Pattern 3: Named Collection withCallState
Good Example - Multiple Call States
// stores/dashboard.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withMethods, patchState } from "@ngrx/signals";
import { withEntities, setAllEntities, type } from "@ngrx/signals/entities";
import {
withCallState,
setLoading,
setLoaded,
setError,
} from "@angular-architects/ngrx-toolkit";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, tap, switchMap, catchError, of } from "rxjs";
interface User {
id: string;
name: string;
}
interface Order {
id: string;
total: number;
}
export const DashboardStore = signalStore(
{ providedIn: "root" },
// Multiple named call states
withCallState({ collections: ["users", "orders"] }),
withEntities({ entity: type<User>(), collection: "user" }),
withEntities({ entity: type<Order>(), collection: "order" }),
withMethods((store) => {
const http = inject(HttpClient);
return {
loadUsers: rxMethod<void>(
pipe(
tap(() => patchState(store, setLoading("users"))),
switchMap(() =>
http.get<User[]>("/api/users").pipe(
catchError((err) => {
patchState(store, setError(err, "users"));
return of([]);
}),
),
),
tap((users) => {
patchState(
store,
setAllEntities(users, { collection: "user" }),
setLoaded("users"),
);
}),
),
),
loadOrders: rxMethod<void>(
pipe(
tap(() => patchState(store, setLoading("orders"))),
switchMap(() =>
http.get<Order[]>("/api/orders").pipe(
catchError((err) => {
patchState(store, setError(err, "orders"));
return of([]);
}),
),
),
tap((orders) => {
patchState(
store,
setAllEntities(orders, { collection: "order" }),
setLoaded("orders"),
);
}),
),
),
};
}),
);
// Template usage
// store.usersLoading(), store.usersLoaded(), store.usersError()
// store.ordersLoading(), store.ordersLoaded(), store.ordersError()Why good: Named collections for multiple call states, independent loading/error tracking, prefixed signals (usersLoading, ordersLoading)
---
Pattern 4: Custom Feature with Type Constraints
Good Example - Feature Requiring Specific State
// features/with-pagination.ts
import { computed } from "@angular/core";
import {
signalStoreFeature,
withState,
withComputed,
withMethods,
patchState,
type,
} from "@ngrx/signals";
const DEFAULT_PAGE_SIZE = 20;
const FIRST_PAGE = 1;
interface PaginationState {
currentPage: number;
pageSize: number;
totalItems: number;
}
interface PaginationComputed {
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
startIndex: number;
endIndex: number;
}
// Feature with type constraints
export function withPagination<T>(itemsKey: keyof T = "items" as keyof T) {
return signalStoreFeature(
// Type constraint: store must have items array
{ state: type<{ items: unknown[] }>() },
withState<PaginationState>({
currentPage: FIRST_PAGE,
pageSize: DEFAULT_PAGE_SIZE,
totalItems: 0,
}),
withComputed(({ currentPage, pageSize, totalItems }) => ({
totalPages: computed(() => Math.ceil(totalItems() / pageSize())),
hasNextPage: computed(
() => currentPage() < Math.ceil(totalItems() / pageSize()),
),
hasPreviousPage: computed(() => currentPage() > FIRST_PAGE),
startIndex: computed(() => (currentPage() - FIRST_PAGE) * pageSize()),
endIndex: computed(() =>
Math.min(currentPage() * pageSize(), totalItems()),
),
})),
withMethods((store) => ({
setPage(page: number) {
const maxPage = Math.ceil(store.totalItems() / store.pageSize());
const validPage = Math.max(FIRST_PAGE, Math.min(page, maxPage));
patchState(store, { currentPage: validPage });
},
nextPage() {
if (store.hasNextPage()) {
this.setPage(store.currentPage() + 1);
}
},
previousPage() {
if (store.hasPreviousPage()) {
this.setPage(store.currentPage() - 1);
}
},
setPageSize(pageSize: number) {
patchState(store, { pageSize, currentPage: FIRST_PAGE });
},
setTotalItems(totalItems: number) {
patchState(store, { totalItems });
},
})),
);
}
// Usage
// stores/paginated-list.store.ts
import {
signalStore,
withState,
withComputed,
patchState,
} from "@ngrx/signals";
import { withPagination } from "../features/with-pagination";
interface ListItem {
id: string;
name: string;
}
export const PaginatedListStore = signalStore(
{ providedIn: "root" },
withState({
items: [] as ListItem[],
}),
withPagination<{ items: ListItem[] }>(),
withComputed((store) => ({
// Paginated items
paginatedItems: computed(() =>
store.items().slice(store.startIndex(), store.endIndex()),
),
})),
);Why good: Type constraint ensures store has required state, generic type parameter for flexibility, computed signals for derived pagination values, named constants
---
Pattern 5: DevTools Integration with withDevtools
Good Example - Redux DevTools Support
// stores/cart.store.ts
import { computed, inject } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
import {
withEntities,
addEntity,
removeEntity,
updateEntity,
} from "@ngrx/signals/entities";
import { withDevtools, updateState } from "@angular-architects/ngrx-toolkit";
const TAX_RATE = 0.08;
interface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
}
interface CartMeta {
couponCode: string | null;
discountPercent: number;
}
export const CartStore = signalStore(
{ providedIn: "root" },
// DevTools integration - name appears in Redux DevTools
withDevtools("cart"),
withEntities<CartItem>(),
withState<CartMeta>({
couponCode: null,
discountPercent: 0,
}),
withComputed(({ entities, discountPercent }) => ({
subtotal: computed(() =>
entities().reduce((sum, item) => sum + item.price * item.quantity, 0),
),
discount: computed(() => {
const subtotal = entities().reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return subtotal * (discountPercent() / 100);
}),
tax: computed(() => {
const subtotal = entities().reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const discount = subtotal * (discountPercent() / 100);
return (subtotal - discount) * TAX_RATE;
}),
total: computed(() => {
const subtotal = entities().reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const discount = subtotal * (discountPercent() / 100);
const tax = (subtotal - discount) * TAX_RATE;
return subtotal - discount + tax;
}),
itemCount: computed(() =>
entities().reduce((sum, item) => sum + item.quantity, 0),
),
})),
withMethods((store) => ({
addItem(product: { id: string; name: string; price: number }) {
const existingItem = store
.entities()
.find((item) => item.productId === product.id);
if (existingItem) {
// Update quantity
patchState(
store,
updateEntity({
id: existingItem.id,
changes: { quantity: existingItem.quantity + 1 },
}),
);
} else {
// Add new item
const cartItem: CartItem = {
id: crypto.randomUUID(),
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
};
patchState(store, addEntity(cartItem));
}
},
removeItem(id: string) {
patchState(store, removeEntity(id));
},
updateQuantity(id: string, quantity: number) {
if (quantity <= 0) {
patchState(store, removeEntity(id));
} else {
patchState(store, updateEntity({ id, changes: { quantity } }));
}
},
applyCoupon(code: string, discountPercent: number) {
patchState(store, { couponCode: code, discountPercent });
},
clearCart() {
const ids = store.ids();
ids.forEach((id) => patchState(store, removeEntity(id)));
patchState(store, { couponCode: null, discountPercent: 0 });
},
})),
);Why good: withDevtools enables Redux DevTools, named store for easy identification, computed signals for cart calculations, TAX_RATE named constant
---
Pattern 6: Feature Composition
Good Example - Combining Multiple Custom Features
// features/with-selection.ts
import { computed } from "@angular/core";
import {
signalStoreFeature,
withState,
withComputed,
withMethods,
patchState,
type,
} from "@ngrx/signals";
interface SelectionState {
selectedIds: Set<string>;
}
export function withSelection() {
return signalStoreFeature(
// Requires entities
{ state: type<{ ids: string[] }>() },
withState<SelectionState>({
selectedIds: new Set<string>(),
}),
withComputed(({ selectedIds, ids }) => ({
hasSelection: computed(() => selectedIds().size > 0),
selectionCount: computed(() => selectedIds().size),
isAllSelected: computed(
() => ids().length > 0 && selectedIds().size === ids().length,
),
})),
withMethods((store) => ({
select(id: string) {
patchState(store, (state) => ({
selectedIds: new Set([...state.selectedIds, id]),
}));
},
deselect(id: string) {
patchState(store, (state) => {
const newSet = new Set(state.selectedIds);
newSet.delete(id);
return { selectedIds: newSet };
});
},
toggle(id: string) {
if (store.selectedIds().has(id)) {
this.deselect(id);
} else {
this.select(id);
}
},
selectAll() {
patchState(store, { selectedIds: new Set(store.ids()) });
},
deselectAll() {
patchState(store, { selectedIds: new Set<string>() });
},
isSelected(id: string): boolean {
return store.selectedIds().has(id);
},
})),
);
}
// Usage: Combining features
// stores/selectable-list.store.ts
import { signalStore, withMethods, patchState } from "@ngrx/signals";
import {
withEntities,
setAllEntities,
removeEntities,
} from "@ngrx/signals/entities";
import { withLoading } from "../features/with-loading";
import { withSelection } from "../features/with-selection";
import { withDevtools } from "@angular-architects/ngrx-toolkit";
interface Item {
id: string;
name: string;
}
export const SelectableListStore = signalStore(
{ providedIn: "root" },
withDevtools("selectable-list"),
withEntities<Item>(),
withLoading(),
withSelection(),
withMethods((store) => ({
async loadItems() {
store.setLoading();
try {
const response = await fetch("/api/items");
const items = await response.json();
patchState(store, setAllEntities(items));
store.setLoaded();
} catch (err) {
store.setError(err instanceof Error ? err.message : "Failed");
}
},
deleteSelected() {
const selectedIds = [...store.selectedIds()];
patchState(store, removeEntities(selectedIds));
store.deselectAll();
},
})),
);Why good: Multiple features composed together (entities, loading, selection, devtools), each feature is independent and reusable, clean store definition
NgRx SignalStore - Migration Examples
Migration patterns from traditional NgRx (actions, reducers, effects, selectors) to NgRx SignalStore.
Prerequisites: Understand core.md (signalStore basics) and effects.md first.
Related examples:
- entities.md - withEntities, CRUD operations
- features.md - signalStoreFeature, custom features
- testing.md - Unit tests, mocking strategies
---
Pattern 1: Migrating Actions to Methods
Traditional NgRx - Actions
// Before: Traditional NgRx actions
// store/counter/counter.actions.ts
import { createAction, props } from "@ngrx/store";
export const increment = createAction("[Counter] Increment");
export const decrement = createAction("[Counter] Decrement");
export const reset = createAction("[Counter] Reset");
export const setCount = createAction(
"[Counter] Set Count",
props<{ count: number }>(),
);SignalStore - Methods
// After: SignalStore methods replace actions
// stores/counter.store.ts
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
const INITIAL_COUNT = 0;
const INCREMENT_STEP = 1;
export const CounterStore = signalStore(
{ providedIn: "root" },
withState({ count: INITIAL_COUNT }),
withMethods((store) => ({
// Actions become methods
increment() {
patchState(store, (state) => ({ count: state.count + INCREMENT_STEP }));
},
decrement() {
patchState(store, (state) => ({ count: state.count - INCREMENT_STEP }));
},
reset() {
patchState(store, { count: INITIAL_COUNT });
},
setCount(count: number) {
patchState(store, { count });
},
})),
);
// Component usage
// Before: this.store.dispatch(increment());
// After: this.store.increment();Why better: No action boilerplate, no action type strings, no separate action files, direct method calls
---
Pattern 2: Migrating Reducers to patchState
Traditional NgRx - Reducer
// Before: Traditional NgRx reducer
// store/todos/todos.reducer.ts
import { createReducer, on } from "@ngrx/store";
import * as TodoActions from "./todos.actions";
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
loading: boolean;
error: string | null;
}
const initialState: TodosState = {
items: [],
loading: false,
error: null,
};
export const todosReducer = createReducer(
initialState,
on(TodoActions.loadTodos, (state) => ({
...state,
loading: true,
error: null,
})),
on(TodoActions.loadTodosSuccess, (state, { todos }) => ({
...state,
items: todos,
loading: false,
})),
on(TodoActions.loadTodosFailure, (state, { error }) => ({
...state,
error,
loading: false,
})),
on(TodoActions.addTodo, (state, { todo }) => ({
...state,
items: [...state.items, todo],
})),
on(TodoActions.toggleTodo, (state, { id }) => ({
...state,
items: state.items.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t,
),
})),
);SignalStore - withState and patchState
// After: SignalStore with patchState
// stores/todo.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, tap, switchMap, catchError, of } from "rxjs";
const TODOS_API_URL = "/api/todos";
interface Todo {
id: string;
title: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
loading: boolean;
error: string | null;
}
export const TodoStore = signalStore(
{ providedIn: "root" },
withState<TodosState>({
items: [],
loading: false,
error: null,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
// loadTodos, loadTodosSuccess, loadTodosFailure combined
loadTodos: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
http.get<Todo[]>(TODOS_API_URL).pipe(
catchError((err) => {
patchState(store, {
error: err.message,
loading: false,
});
return of([]);
}),
),
),
tap((items) => patchState(store, { items, loading: false })),
),
),
// addTodo with patchState
addTodo(title: string) {
const todo: Todo = {
id: crypto.randomUUID(),
title,
completed: false,
};
patchState(store, (state) => ({
items: [...state.items, todo],
}));
},
// toggleTodo with patchState
toggleTodo(id: string) {
patchState(store, (state) => ({
items: state.items.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t,
),
}));
},
};
}),
);Why better: No reducer boilerplate, no action matching, single source of truth, rxMethod handles async loading pattern
---
Pattern 3: Migrating Selectors to withComputed
Traditional NgRx - Selectors
// Before: Traditional NgRx selectors
// store/todos/todos.selectors.ts
import { createFeatureSelector, createSelector } from "@ngrx/store";
interface TodosState {
items: Todo[];
filter: "all" | "active" | "completed";
}
const selectTodosState = createFeatureSelector<TodosState>("todos");
export const selectAllTodos = createSelector(
selectTodosState,
(state) => state.items,
);
export const selectFilter = createSelector(
selectTodosState,
(state) => state.filter,
);
export const selectFilteredTodos = createSelector(
selectAllTodos,
selectFilter,
(todos, filter) => {
switch (filter) {
case "active":
return todos.filter((t) => !t.completed);
case "completed":
return todos.filter((t) => t.completed);
default:
return todos;
}
},
);
export const selectCompletedCount = createSelector(
selectAllTodos,
(todos) => todos.filter((t) => t.completed).length,
);
export const selectActiveCount = createSelector(
selectAllTodos,
(todos) => todos.filter((t) => !t.completed).length,
);SignalStore - withComputed
// After: SignalStore with computed signals
// stores/todo.store.ts
import { computed } from "@angular/core";
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from "@ngrx/signals";
interface Todo {
id: string;
title: string;
completed: boolean;
}
type TodoFilter = "all" | "active" | "completed";
export const TodoStore = signalStore(
{ providedIn: "root" },
withState({
items: [] as Todo[],
filter: "all" as TodoFilter,
}),
// Selectors become computed signals
withComputed(({ items, filter }) => ({
filteredTodos: computed(() => {
const allItems = items();
const currentFilter = filter();
switch (currentFilter) {
case "active":
return allItems.filter((t) => !t.completed);
case "completed":
return allItems.filter((t) => t.completed);
default:
return allItems;
}
}),
completedCount: computed(() => items().filter((t) => t.completed).length),
activeCount: computed(() => items().filter((t) => !t.completed).length),
totalCount: computed(() => items().length),
})),
withMethods((store) => ({
setFilter(filter: TodoFilter) {
patchState(store, { filter });
},
// Other methods...
})),
);
// Component usage
// Before: this.store.select(selectFilteredTodos)
// After: this.store.filteredTodos()Why better: No selector factory boilerplate, automatic memoization, direct signal access in templates, no need for async pipe
---
Pattern 4: Migrating Effects to rxMethod
Traditional NgRx - Effects
// Before: Traditional NgRx effects
// store/users/users.effects.ts
import { Injectable } from "@angular/core";
import { Actions, createEffect, ofType } from "@ngrx/effects";
import { of } from "rxjs";
import {
map,
switchMap,
catchError,
debounceTime,
distinctUntilChanged,
} from "rxjs/operators";
import * as UserActions from "./users.actions";
import { UserService } from "../../services/user.service";
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsers),
switchMap(() =>
this.userService.getUsers().pipe(
map((users) => UserActions.loadUsersSuccess({ users })),
catchError((error) => of(UserActions.loadUsersFailure({ error }))),
),
),
),
);
searchUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.searchUsers),
debounceTime(300),
distinctUntilChanged((prev, curr) => prev.query === curr.query),
switchMap(({ query }) =>
this.userService.searchUsers(query).pipe(
map((users) => UserActions.searchUsersSuccess({ users })),
catchError((error) => of(UserActions.searchUsersFailure({ error }))),
),
),
),
);
constructor(
private actions$: Actions,
private userService: UserService,
) {}
}SignalStore - rxMethod
// After: SignalStore with rxMethod
// stores/user.store.ts
import { inject } from "@angular/core";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import {
pipe,
tap,
switchMap,
catchError,
of,
debounceTime,
distinctUntilChanged,
} from "rxjs";
import { UserService } from "../services/user.service";
const SEARCH_DEBOUNCE_MS = 300;
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
users: User[];
isLoading: boolean;
error: string | null;
}
export const UserStore = signalStore(
{ providedIn: "root" },
withState<UserState>({
users: [],
isLoading: false,
error: null,
}),
withMethods((store) => {
const userService = inject(UserService);
return {
// loadUsers effect becomes rxMethod
loadUsers: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
userService.getUsers().pipe(
catchError((err) => {
patchState(store, { error: err.message, isLoading: false });
return of([]);
}),
),
),
tap((users) => patchState(store, { users, isLoading: false })),
),
),
// searchUsers effect with debounce
searchUsers: rxMethod<string>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
debounceTime(SEARCH_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) =>
userService.searchUsers(query).pipe(
catchError((err) => {
patchState(store, { error: err.message, isLoading: false });
return of([]);
}),
),
),
tap((users) => patchState(store, { users, isLoading: false })),
),
),
};
}),
);Why better: No separate effects class, no action dispatching, direct patchState calls, service injected in context, cleaner async flow
---
Pattern 5: Migrating Entity State
Traditional NgRx - Entity Adapter
// Before: Traditional NgRx with @ngrx/entity
// store/products/products.reducer.ts
import { createReducer, on } from "@ngrx/store";
import { createEntityAdapter, EntityState } from "@ngrx/entity";
import * as ProductActions from "./products.actions";
interface Product {
id: string;
name: string;
price: number;
}
interface ProductsState extends EntityState<Product> {
loading: boolean;
error: string | null;
}
const adapter = createEntityAdapter<Product>({
selectId: (product) => product.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
const initialState: ProductsState = adapter.getInitialState({
loading: false,
error: null,
});
export const productsReducer = createReducer(
initialState,
on(ProductActions.loadProductsSuccess, (state, { products }) =>
adapter.setAll(products, { ...state, loading: false }),
),
on(ProductActions.addProduct, (state, { product }) =>
adapter.addOne(product, state),
),
on(ProductActions.updateProduct, (state, { update }) =>
adapter.updateOne(update, state),
),
on(ProductActions.removeProduct, (state, { id }) =>
adapter.removeOne(id, state),
),
);
// Selectors
export const {
selectAll: selectAllProducts,
selectEntities: selectProductEntities,
selectIds: selectProductIds,
selectTotal: selectTotalProducts,
} = adapter.getSelectors();SignalStore - withEntities
// After: SignalStore with withEntities
// stores/product.store.ts
import { computed, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
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 { pipe, tap, switchMap, catchError, of } from "rxjs";
const PRODUCTS_API_URL = "/api/products";
interface Product {
id: string;
name: string;
price: number;
}
export const ProductStore = signalStore(
{ providedIn: "root" },
// withEntities replaces createEntityAdapter
withEntities<Product>(),
withState({
loading: false,
error: null as string | null,
}),
// Computed replaces adapter selectors
withComputed(({ entities }) => ({
sortedProducts: computed(() =>
[...entities()].sort((a, b) => a.name.localeCompare(b.name)),
),
totalValue: computed(() => entities().reduce((sum, p) => sum + p.price, 0)),
})),
withMethods((store) => {
const http = inject(HttpClient);
return {
loadProducts: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
http.get<Product[]>(PRODUCTS_API_URL).pipe(
catchError((err) => {
patchState(store, { error: err.message, loading: false });
return of([]);
}),
),
),
tap((products) => {
// setAllEntities replaces adapter.setAll
patchState(store, setAllEntities(products), { loading: false });
}),
),
),
addProduct(product: Product) {
// addEntity replaces adapter.addOne
patchState(store, addEntity(product));
},
updateProduct(id: string, changes: Partial<Product>) {
// updateEntity replaces adapter.updateOne
patchState(store, updateEntity({ id, changes }));
},
removeProduct(id: string) {
// removeEntity replaces adapter.removeOne
patchState(store, removeEntity(id));
},
};
}),
);
// Component usage
// Before: this.store.select(selectAllProducts)
// After: this.store.entities() or this.store.sortedProducts()Why better: No adapter setup, built-in entity signals (ids, entityMap, entities), entity updaters work with patchState, computed for derived data
---
Pattern 6: Gradual Migration Strategy
Good Example - Hybrid Approach During Migration
// During migration: Both systems can coexist
// 1. Keep existing NgRx for complex features
// store/auth/auth.module.ts (existing NgRx)
import { StoreModule } from "@ngrx/store";
import { EffectsModule } from "@ngrx/effects";
import { authReducer } from "./auth.reducer";
import { AuthEffects } from "./auth.effects";
@NgModule({
imports: [
StoreModule.forFeature("auth", authReducer),
EffectsModule.forFeature([AuthEffects]),
],
})
export class AuthStoreModule {}
// 2. New features use SignalStore
// stores/notifications.store.ts (new SignalStore)
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
export const NotificationStore = signalStore(
{ providedIn: "root" },
withState({
notifications: [] as Notification[],
}),
withMethods((store) => ({
// New SignalStore implementation
})),
);
// 3. Component can inject both
// components/dashboard.component.ts
import { Component, inject } from "@angular/core";
import { Store } from "@ngrx/store";
import { selectCurrentUser } from "../store/auth/auth.selectors";
import { NotificationStore } from "../stores/notifications.store";
@Component({
selector: "app-dashboard",
template: `
<!-- NgRx with async pipe -->
<app-user-card [user]="currentUser$ | async" />
<!-- SignalStore with direct access -->
<app-notification-list [items]="notificationStore.notifications()" />
`,
})
export class DashboardComponent {
// Traditional NgRx
private ngRxStore = inject(Store);
currentUser$ = this.ngRxStore.select(selectCurrentUser);
// New SignalStore
notificationStore = inject(NotificationStore);
}Why useful: Allows gradual migration, no big-bang rewrite, teams can migrate feature by feature, both systems work together
---
Migration Checklist
| Traditional NgRx | SignalStore Equivalent | Notes |
|---|---|---|
createAction() | Method in withMethods() | No action types needed |
createReducer() | patchState() in methods | No case matching |
createSelector() | withComputed() | Auto-memoized |
createEffect() | rxMethod() | In store, not separate class |
createEntityAdapter() | withEntities() | Built-in CRUD |
provideMockStore() | unprotected() + TestBed | Different testing approach |
dispatch(action) | store.method() | Direct method call |
select(selector) | store.computed() | Signal access |
StoreModule.forFeature() | { providedIn: 'root' } | Or component providers |
Migration Steps:
1. Create SignalStore alongside existing NgRx feature 2. Migrate state shape to withState() / withEntities() 3. Convert selectors to withComputed() 4. Convert effects to rxMethod() 5. Convert actions/reducers to withMethods() + patchState() 6. Update components to use SignalStore 7. Remove old NgRx feature files 8. Update tests
NgRx SignalStore - Testing Examples
Testing patterns for NgRx SignalStore including unit tests, mocking strategies, and component integration.
Prerequisites: Understand core.md (signalStore basics) first.
Related examples:
- entities.md - withEntities, CRUD operations
- effects.md - rxMethod, side effects
- features.md - signalStoreFeature, custom features
---
Pattern 1: Basic Store Unit Tests
Good Example - Testing Store Methods
// stores/counter.store.ts
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
const INITIAL_COUNT = 0;
const INCREMENT_STEP = 1;
export const CounterStore = signalStore(
withState({ count: INITIAL_COUNT }),
withMethods((store) => ({
increment() {
patchState(store, (state) => ({ count: state.count + INCREMENT_STEP }));
},
decrement() {
patchState(store, (state) => ({ count: state.count - INCREMENT_STEP }));
},
reset() {
patchState(store, { count: INITIAL_COUNT });
},
setCount(count: number) {
patchState(store, { count });
},
})),
);
// stores/counter.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach
import { Injector } from "@angular/core";
import { TestBed, runInInjectionContext } from "@angular/core/testing";
import { CounterStore } from "./counter.store";
const INITIAL_COUNT = 0;
const TEST_COUNT = 42;
describe("CounterStore", () => {
let store: InstanceType<typeof CounterStore>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CounterStore],
});
store = runInInjectionContext(TestBed.inject(Injector), () =>
TestBed.inject(CounterStore),
);
});
it("should have initial count of 0", () => {
expect(store.count()).toBe(INITIAL_COUNT);
});
it("should increment count", () => {
store.increment();
expect(store.count()).toBe(1);
store.increment();
expect(store.count()).toBe(2);
});
it("should decrement count", () => {
store.setCount(5);
store.decrement();
expect(store.count()).toBe(4);
});
it("should reset count to initial value", () => {
store.setCount(TEST_COUNT);
expect(store.count()).toBe(TEST_COUNT);
store.reset();
expect(store.count()).toBe(INITIAL_COUNT);
});
it("should set count to specific value", () => {
store.setCount(TEST_COUNT);
expect(store.count()).toBe(TEST_COUNT);
});
});Why good: TestBed for Angular DI, runInInjectionContext for store instantiation, named constants for test values, clean test structure
---
Pattern 2: Testing Store with Mocked Services
Good Example - Mocking HttpClient in Store Tests
// stores/user.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { rxMethod } from "@ngrx/signals/rxjs-interop";
import { pipe, tap, switchMap, catchError, of } from "rxjs";
const USERS_API_URL = "/api/users";
interface User {
id: string;
name: string;
email: string;
}
interface UserState {
users: User[];
isLoading: boolean;
error: string | null;
}
export const UserStore = signalStore(
{ providedIn: "root" },
withState<UserState>({
users: [],
isLoading: false,
error: null,
}),
withMethods((store) => {
const http = inject(HttpClient);
return {
loadUsers: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
http.get<User[]>(USERS_API_URL).pipe(
catchError((err) => {
patchState(store, {
error: err.message ?? "Failed to load users",
isLoading: false,
});
return of([]);
}),
),
),
tap((users) => {
patchState(store, { users, isLoading: false });
}),
),
),
};
}),
);
// stores/user.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach, mock utilities
import { TestBed, fakeAsync, tick } from "@angular/core/testing";
import { HttpClient } from "@angular/common/http";
import { of, throwError } from "rxjs";
import { UserStore } from "./user.store";
const MOCK_USERS = [
{ id: "1", name: "Alice", email: "alice@test.com" },
{ id: "2", name: "Bob", email: "bob@test.com" },
];
describe("UserStore", () => {
let store: InstanceType<typeof UserStore>;
let httpMock: { get: (...args: unknown[]) => unknown };
beforeEach(() => {
httpMock = {
get: createMockFn(), // Use your test runner's mock function creator
};
TestBed.configureTestingModule({
providers: [UserStore, { provide: HttpClient, useValue: httpMock }],
});
store = TestBed.inject(UserStore);
});
it("should have empty initial state", () => {
expect(store.users()).toEqual([]);
expect(store.isLoading()).toBe(false);
expect(store.error()).toBeNull();
});
it("should load users successfully", fakeAsync(() => {
httpMock.get.mockReturnValue(of(MOCK_USERS));
store.loadUsers();
tick();
expect(store.users()).toEqual(MOCK_USERS);
expect(store.isLoading()).toBe(false);
expect(store.error()).toBeNull();
}));
it("should handle loading state", fakeAsync(() => {
httpMock.get.mockReturnValue(of(MOCK_USERS));
// Before load completes
store.loadUsers();
expect(store.isLoading()).toBe(true);
tick();
expect(store.isLoading()).toBe(false);
}));
it("should handle error", fakeAsync(() => {
const errorMessage = "Network error";
httpMock.get.mockReturnValue(throwError(() => new Error(errorMessage)));
store.loadUsers();
tick();
expect(store.users()).toEqual([]);
expect(store.isLoading()).toBe(false);
expect(store.error()).toBe(errorMessage);
}));
});Why good: Mock HttpClient with mock functions, fakeAsync/tick for async tests, tests loading, success, and error states, named constants for mock data
---
Pattern 3: Testing with unprotected() Utility
Good Example - Using unprotected for State Access (v19.1+)
// stores/cart.store.ts
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
interface CartItem {
id: string;
name: string;
quantity: number;
}
interface CartState {
items: CartItem[];
isCheckingOut: boolean;
}
export const CartStore = signalStore(
{ providedIn: "root" },
withState<CartState>({
items: [],
isCheckingOut: false,
}),
withMethods((store) => ({
addItem(item: Omit<CartItem, "quantity">) {
const existing = store.items().find((i) => i.id === item.id);
if (existing) {
patchState(store, (state) => ({
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
),
}));
} else {
patchState(store, (state) => ({
items: [...state.items, { ...item, quantity: 1 }],
}));
}
},
startCheckout() {
patchState(store, { isCheckingOut: true });
},
clearCart() {
patchState(store, { items: [], isCheckingOut: false });
},
})),
);
// stores/cart.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach
import { TestBed } from "@angular/core/testing";
import { unprotected } from "@ngrx/signals/testing";
import { patchState } from "@ngrx/signals";
import { CartStore } from "./cart.store";
const MOCK_ITEM = { id: "1", name: "Test Product" };
const MOCK_CART_ITEMS = [
{ id: "1", name: "Product 1", quantity: 2 },
{ id: "2", name: "Product 2", quantity: 1 },
];
describe("CartStore", () => {
let store: InstanceType<typeof CartStore>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CartStore],
});
store = TestBed.inject(CartStore);
});
describe("addItem", () => {
it("should add new item with quantity 1", () => {
store.addItem(MOCK_ITEM);
expect(store.items()).toHaveLength(1);
expect(store.items()[0]).toEqual({ ...MOCK_ITEM, quantity: 1 });
});
it("should increment quantity for existing item", () => {
store.addItem(MOCK_ITEM);
store.addItem(MOCK_ITEM);
expect(store.items()).toHaveLength(1);
expect(store.items()[0].quantity).toBe(2);
});
});
describe("using unprotected() for test setup", () => {
it("should allow direct state modification in tests", () => {
// Use unprotected() to bypass state protection for test setup
patchState(unprotected(store), { items: MOCK_CART_ITEMS });
expect(store.items()).toHaveLength(2);
expect(store.items()[0].quantity).toBe(2);
});
it("should test checkout with pre-populated cart", () => {
// Setup: Pre-populate cart using unprotected
patchState(unprotected(store), { items: MOCK_CART_ITEMS });
// Action
store.startCheckout();
// Assert
expect(store.isCheckingOut()).toBe(true);
expect(store.items()).toHaveLength(2);
});
it("should clear cart correctly", () => {
// Setup
patchState(unprotected(store), {
items: MOCK_CART_ITEMS,
isCheckingOut: true,
});
// Action
store.clearCart();
// Assert
expect(store.items()).toHaveLength(0);
expect(store.isCheckingOut()).toBe(false);
});
});
});Why good: unprotected() bypasses state protection for test setup, allows setting initial state for specific test scenarios, cleaner test arrangement
---
Pattern 4: Testing Entity Store
Good Example - Testing withEntities Store
// stores/todo.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach
import { TestBed } from "@angular/core/testing";
import { unprotected } from "@ngrx/signals/testing";
import { patchState } from "@ngrx/signals";
import { setAllEntities } from "@ngrx/signals/entities";
import { TodoStore } from "./todo.store";
interface Todo {
id: string;
title: string;
completed: boolean;
}
const MOCK_TODOS: Todo[] = [
{ id: "1", title: "First todo", completed: false },
{ id: "2", title: "Second todo", completed: true },
{ id: "3", title: "Third todo", completed: false },
];
describe("TodoStore", () => {
let store: InstanceType<typeof TodoStore>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TodoStore],
});
store = TestBed.inject(TodoStore);
});
describe("entity operations", () => {
it("should set all todos", () => {
store.setTodos(MOCK_TODOS);
expect(store.entities()).toHaveLength(3);
expect(store.ids()).toEqual(["1", "2", "3"]);
});
it("should add a new todo", () => {
store.addTodo("New todo");
expect(store.entities()).toHaveLength(1);
expect(store.entities()[0].title).toBe("New todo");
expect(store.entities()[0].completed).toBe(false);
});
it("should toggle todo completion", () => {
store.setTodos(MOCK_TODOS);
store.toggleTodo("1");
const todo = store.entities().find((t) => t.id === "1");
expect(todo?.completed).toBe(true);
});
it("should remove todo", () => {
store.setTodos(MOCK_TODOS);
store.removeTodo("2");
expect(store.entities()).toHaveLength(2);
expect(store.ids()).not.toContain("2");
});
});
describe("computed signals", () => {
beforeEach(() => {
// Setup todos using unprotected
patchState(unprotected(store), setAllEntities(MOCK_TODOS));
});
it("should compute completed count", () => {
expect(store.completedCount()).toBe(1);
});
it("should compute active count", () => {
expect(store.activeCount()).toBe(2);
});
it("should filter by status", () => {
store.setFilter("completed");
expect(store.filteredTodos()).toHaveLength(1);
store.setFilter("active");
expect(store.filteredTodos()).toHaveLength(2);
store.setFilter("all");
expect(store.filteredTodos()).toHaveLength(3);
});
});
});Why good: Tests entity operations (add, update, remove), tests computed signals, uses unprotected for setup, named constants for mock data
---
Pattern 5: Component Integration Tests
Good Example - Testing Component with Store
// components/counter.component.ts
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import { CounterStore } from "../stores/counter.store";
@Component({
selector: "app-counter",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<span data-testid="count">{{ store.count() }}</span>
<button data-testid="increment" (click)="store.increment()">+</button>
<button data-testid="decrement" (click)="store.decrement()">-</button>
<button data-testid="reset" (click)="store.reset()">Reset</button>
</div>
`,
})
export class CounterComponent {
readonly store = inject(CounterStore);
}
// components/counter.component.spec.ts
// Use your test runner's describe, it, expect, beforeEach
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { CounterComponent } from "./counter.component";
import { CounterStore } from "../stores/counter.store";
describe("CounterComponent", () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
providers: [CounterStore],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should display initial count", () => {
const countEl = fixture.debugElement.query(By.css('[data-testid="count"]'));
expect(countEl.nativeElement.textContent).toBe("0");
});
it("should increment count when + button clicked", () => {
const incrementBtn = fixture.debugElement.query(
By.css('[data-testid="increment"]'),
);
incrementBtn.nativeElement.click();
fixture.detectChanges();
const countEl = fixture.debugElement.query(By.css('[data-testid="count"]'));
expect(countEl.nativeElement.textContent).toBe("1");
});
it("should decrement count when - button clicked", () => {
// Setup: increment first
component.store.increment();
component.store.increment();
fixture.detectChanges();
const decrementBtn = fixture.debugElement.query(
By.css('[data-testid="decrement"]'),
);
decrementBtn.nativeElement.click();
fixture.detectChanges();
const countEl = fixture.debugElement.query(By.css('[data-testid="count"]'));
expect(countEl.nativeElement.textContent).toBe("1");
});
it("should reset count when Reset button clicked", () => {
// Setup
component.store.setCount(42);
fixture.detectChanges();
const resetBtn = fixture.debugElement.query(
By.css('[data-testid="reset"]'),
);
resetBtn.nativeElement.click();
fixture.detectChanges();
const countEl = fixture.debugElement.query(By.css('[data-testid="count"]'));
expect(countEl.nativeElement.textContent).toBe("0");
});
});Why good: Tests component with real store, uses data-testid for reliable selectors, tests user interactions, fixture.detectChanges() after state changes
---
Pattern 6: Testing rxMethod Effects
Good Example - Testing Async Effects
// stores/search.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach, mock utilities
import { TestBed, fakeAsync, tick } from "@angular/core/testing";
import { HttpClient } from "@angular/common/http";
import { of, delay } from "rxjs";
import { SearchStore } from "./search.store";
const DEBOUNCE_MS = 300;
const MOCK_RESULTS = [
{ id: "1", title: "Result 1" },
{ id: "2", title: "Result 2" },
];
describe("SearchStore", () => {
let store: InstanceType<typeof SearchStore>;
let httpMock: { get: (...args: unknown[]) => unknown };
beforeEach(() => {
httpMock = {
get: createMockFn().mockReturnValue(of(MOCK_RESULTS)), // Use your test runner's mock
};
TestBed.configureTestingModule({
providers: [SearchStore, { provide: HttpClient, useValue: httpMock }],
});
store = TestBed.inject(SearchStore);
});
describe("search rxMethod", () => {
it("should debounce search queries", fakeAsync(() => {
// Rapid searches
store.search("a");
store.search("ab");
store.search("abc");
// Before debounce
expect(httpMock.get).not.toHaveBeenCalled();
// After debounce
tick(DEBOUNCE_MS);
// Only last query should be sent
expect(httpMock.get).toHaveBeenCalledTimes(1);
expect(httpMock.get).toHaveBeenCalledWith(
expect.stringContaining("q=abc"),
);
}));
it("should not search empty query", fakeAsync(() => {
store.search("");
tick(DEBOUNCE_MS);
expect(httpMock.get).not.toHaveBeenCalled();
expect(store.results()).toEqual([]);
}));
it("should update results after search", fakeAsync(() => {
store.search("test");
tick(DEBOUNCE_MS);
expect(store.results()).toEqual(MOCK_RESULTS);
expect(store.isLoading()).toBe(false);
}));
it("should handle duplicate queries", fakeAsync(() => {
store.search("test");
tick(DEBOUNCE_MS);
store.search("test"); // Same query
tick(DEBOUNCE_MS);
// distinctUntilChanged should prevent duplicate API calls
expect(httpMock.get).toHaveBeenCalledTimes(1);
}));
});
});Why good: Tests debounce behavior with fakeAsync/tick, tests distinctUntilChanged, mock HttpClient, named constant for debounce timing
---
Pattern 7: Testing with withOptionalHooks
Good Example - Avoiding onInit Side Effects in Tests
// Alternative approach: disable hooks during testing
// stores/data.store.ts
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import {
signalStore,
withState,
withMethods,
withHooks,
patchState,
} from "@ngrx/signals";
export const DataStore = signalStore(
{ providedIn: "root" },
withState({
data: [] as string[],
initialized: false,
}),
withMethods((store) => ({
load() {
// Load logic
patchState(store, { initialized: true });
},
})),
withHooks({
onInit({ load }) {
// This runs immediately - can cause issues in tests
load();
},
}),
);
// Test approach 1: Override the store
// stores/data.store.spec.ts
// Use your test runner's describe, it, expect, beforeEach
import { TestBed } from "@angular/core/testing";
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
// Create test-specific store WITHOUT hooks
const TestDataStore = signalStore(
withState({
data: [] as string[],
initialized: false,
}),
withMethods((store) => ({
load() {
patchState(store, { initialized: true });
},
})),
// No withHooks!
);
describe("DataStore (without hooks)", () => {
let store: InstanceType<typeof TestDataStore>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TestDataStore],
});
store = TestBed.inject(TestDataStore);
});
it("should not auto-initialize", () => {
// Without hooks, store starts uninitialized
expect(store.initialized()).toBe(false);
});
it("should initialize when load is called", () => {
store.load();
expect(store.initialized()).toBe(true);
});
});Why good: Shows how to handle onInit side effects in tests, test-specific store without hooks, isolates behavior for testing
# yaml-language-server: $schema=https://raw.githubusercontent.com/agents-inc/cli/main/src/schemas/metadata.schema.json
category: web-client-state
slug: ngrx-signalstore
domain: web
author: "@vince"
displayName: NgRx SignalStore
cliDescription: Angular signal-based state
usageGuidance: Use when managing Angular state with Signals and SignalStore.
Related skills
FAQ
How do I update SignalStore state?
Use patchState() for all state updates and never mutate state directly; it ensures immutable updates without an Immer dependency.
How do async operations integrate with SignalStore?
Wrap async operations in rxMethod() from @ngrx/signals/rxjs-interop to bridge Angular Signals with RxJS.