name: ngrx-signals-store-management description: Create, modify and manage NgRx Signal Stores based on a given model. Use this when asked to create or modify a store with ngrx signals, manage events with eventGroup, add computed signals, or handle signal dependencies with withLinkedState. license: MIT
NgRx Signals Store Management
When to Use This Skill
Use this skill when:
- Creating a new NgRx Signal Store for a feature
- Modifying an existing NgRx Signal Store
- Adding event management with eventGroup
- Creating computed signals with withComputed
- Managing signal dependencies with withLinkedState
- Setting up state management for Angular components
Core Principles
0. Event-Driven Architecture (CRITICAL - MANDATORY)
⚠️ ABSOLUTE RULE: All stores MUST use event-driven architecture with Events plugin
This is NON-NEGOTIABLE. Never create a store without this pattern.
Required Pattern Components
- Events with eventGroup (
store/feature.events.ts)
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
export const featureEvents = eventGroup({
source: 'Feature Name',
events: {
opened: type<void>(),
queryChanged: type<string>(),
itemSelected: type<number>(),
},
});
- Event Handlers with withEventHandlers (
store/index.ts)
import { Events, Dispatcher, withEventHandlers } from '@ngrx/signals/events';
// CRITICAL: Must inject Events service for event listening
withEventHandlers(
(store, events = inject(Events), service = inject(SomeService)) => ({
// Observable ending in $ - reacts to events
onFeatureOpened$: events
.on(featureEvents.opened, featureEvents.queryChanged)
.pipe(
switchMap(() =>
service.getData().pipe(
mapResponse({
next: (data) => apiEvents.loadSuccess(data),
error: (error) => apiEvents.loadFailure(error.message),
})
)
)
),
// Another handler for logging
onError$: events
.on(apiEvents.loadFailure)
.pipe(tap(({ payload }) => console.error(payload))),
})
)
- State Transitions with withReducer (REQUIRED for state updates)
import { on, withReducer } from '@ngrx/signals/events';
// CRITICAL: Use withReducer with on() for ALL state updates in response to events
withReducer(
// Simple state update
on(featureEvents.queryChanged, ({ payload: query }) => ({
filter: { query },
})),
// Access current state via second parameter
on(featureEvents.itemSelected, ({ payload: id }, state) => ({
selectedItem: state.items.find(item => item.id === id) ?? null,
})),
// Multiple state updates
on(apiEvents.loadSuccess, ({ payload: items }) => ({
items,
isLoading: false,
error: null,
})),
// Conditional logic with state access
on(featureEvents.experienceGained, ({ payload: amount }, state) => {
const newExp = state.experience + amount;
if (newExp >= 100) {
return {
experience: newExp - 100,
level: state.level + 1,
};
}
return { experience: newExp };
})
)
- Side Effects with withEventHandlers (ONLY for impure operations)
import { Events, withEventHandlers } from '@ngrx/signals/events';
import { switchMap, tap } from 'rxjs';
import { mapResponse } from '@ngrx/operators';
// CRITICAL: Use withEventHandlers ONLY for side effects (API calls, logging, etc.)
withEventHandlers(
(store, events = inject(Events), service = inject(SomeService)) => ({
// API call side effect
loadData$: events
.on(featureEvents.opened, featureEvents.queryChanged)
.pipe(
switchMap(() =>
service.getData(store.query()).pipe(
mapResponse({
next: (data) => apiEvents.loadSuccess(data),
error: (error: { message: string }) =>
apiEvents.loadFailure(error.message),
})
)
)
),
// Logging side effect
logError$: events
.on(apiEvents.loadFailure)
.pipe(tap(({ payload }) => console.error(payload))),
})
)
- Event Dispatching with Dispatcher (in methods or hooks)
// In withMethods
withMethods((store, dispatcher = inject(Dispatcher)) => ({
startAction(): void {
// Dispatch events using Dispatcher
dispatcher.dispatch(featureEvents.opened());
},
}))
// In withHooks
withHooks({
onInit(store, dispatcher = inject(Dispatcher)) {
dispatcher.dispatch(featureEvents.pageOpened());
},
})
- Component Integration with injectDispatch
import { injectDispatch } from '@ngrx/signals/events';
@Component({
// ...
})
export class FeatureComponent {
readonly store = inject(FeatureStore);
readonly dispatch = injectDispatch(featureEvents); // Simplified dispatching
onUserAction(): void {
// Direct event dispatch - cleaner syntax
this.dispatch.queryChanged('new query');
}
}
Architecture Flow
[Component]
↓ calls method / uses injectDispatch
[Store Methods / Component]
↓ dispatcher.dispatch(event) or dispatch.eventName()
[Dispatcher]
↓ broadcasts event
[State Transitions (withReducer)]
↓ on() catches events and updates state FIRST
↓ pure state updates (no side effects)
[Event Handlers (withEventHandlers)]
↓ events.on() listens and reacts AFTER state is updated
↓ performs side effects (API calls, logging, etc.)
↓ can dispatch new events (success/failure)
[State Updated (if handlers dispatch new events)]
↓ signals react
[Component Re-renders]
Key Points:
- withReducer runs BEFORE withEventHandlers (state updates happen first)
- withReducer = synchronous, pure state transitions
- withEventHandlers = asynchronous, impure side effects
Critical Rules for Event-Driven Architecture
⚠️ MOST IMPORTANT RULE: withReducer vs withEventHandlers
┌─────────────────────────────────────────────────────────────┐
│ withReducer → State Transitions (PURE) │
│ - Modify state in response to events │
│ - NO side effects │
│ - NO API calls │
│ - NO logging │
│ - Return partial state objects │
│ │
│ withEventHandlers → Side Effects (IMPURE) │
│ - API calls via services │
│ - Logging │
│ - External communications │
│ - Return observables ($) │
│ - CAN dispatch new events (e.g., success/failure) │
└─────────────────────────────────────────────────────────────┘
Decision Tree:
Need to update state in response to an event?
│
├─ State update ONLY (no API, no logging)
│ → Use withReducer with on()
│ → Example: on(userSelected, ({ payload }) => ({ selectedUser: payload }))
│
└─ Need side effects (API, logging, etc.)
→ Use withEventHandlers with events.on()
→ Example: events.on(pageOpened).pipe(switchMap(() => api.getData()))
Examples:
❌ WRONG - Using withEventHandlers for state transitions:
withEventHandlers((store, events = inject(Events)) => ({
onUserSelected$: events.on(userSelected).pipe(
tap(({ payload }) => {
patchState(store, { selectedUser: payload }); // ❌ DON'T DO THIS
})
),
}))
✅ CORRECT - Using withReducer for state transitions:
withReducer(
on(userSelected, ({ payload }) => ({
selectedUser: payload, // ✅ Pure state update
}))
)
❌ WRONG - Using withReducer for API calls:
withReducer(
on(pageOpened, () => {
api.getData(); // ❌ Side effect in reducer!
return { isLoading: true };
})
)
✅ CORRECT - Using withEventHandlers for API calls:
withEventHandlers((store, events = inject(Events), api = inject(ApiService)) => ({
loadData$: events.on(pageOpened).pipe(
switchMap(() => api.getData().pipe( // ✅ Side effect in handler
mapResponse({
next: (data) => apiEvents.loadSuccess(data),
error: (error) => apiEvents.loadFailure(error.message),
})
))
),
}))
// Then handle the result in withReducer
withReducer(
on(pageOpened, () => ({ isLoading: true })),
on(apiEvents.loadSuccess, ({ payload }) => ({
data: payload,
isLoading: false,
}))
)
General Rules
- ✅ ALWAYS use
eventGroupwithtype<>()for event definitions - ✅ ALWAYS use
withReducerwithon()for state transitions (pure functions) - ✅ ALWAYS use
withEventHandlerswithevents.on()for side effects (API, logging) - ✅ ALWAYS import
Eventsfrom'@ngrx/signals/events'when using withEventHandlers - ✅ ALWAYS inject
EventsinwithEventHandlers:events = inject(Events) - ✅ ALWAYS return observables ending in
$from event handlers - ✅ ALWAYS inject
Dispatcherwhen dispatching events in methods/hooks - ✅ ALWAYS use
dispatcher.dispatch(event())for manual dispatching - ✅ ALWAYS use
injectDispatch()in components for cleaner syntax - ✅ In
withReducer, access current state via second parameter:on(event, ({ payload }, state) => ({ ... })) - ❌ NEVER use
patchStateinwithEventHandlers- usewithReducerinstead - ❌ NEVER perform side effects in
withReducer- usewithEventHandlersinstead - ❌ NEVER call event creators directly without dispatching
- ❌ NEVER skip the Events plugin - event-driven architecture is mandatory
- ❌ NEVER skip the Events plugin - it's mandatory
1. Store Creation/Verification
Always follow this sequence when creating a store:
- Analyze the model - Understand data structure, dependencies, and events
- Define types - Create explicit TypeScript interfaces
- Create store folder - Create
store/directory inside feature folder - Define events - Use
eventGroupwithtype<>()instore/feature.events.ts - Create store - Use
signalStore()instore/index.tswith proper feature composition - Integrate - Provide and inject in components
2. Folder Structure (MANDATORY)
Every feature MUST follow this structure:
feature/
├── models/ # Domain models only (entities, DTOs)
│ └── feature.model.ts
├── services/ # API/Business services
│ └── feature.service.ts
├── store/ # ⭐ State management (REQUIRED)
│ ├── index.ts # State interface + initial state + signal store
│ └── feature.events.ts # Event groups
├── feature.component.ts
└── feature.component.html
Key Rules:
- ✅ State interface lives in
store/index.ts(NOT in models/) - ✅ Events live in
store/feature.events.ts(separate file) - ✅ Store is exported from
store/index.ts - ✅ Import as:
import { FeatureStore } from './store';
3. Event Management with eventGroup
CRITICAL: Always use type<>() syntax, NEVER use payload<>() or emptyProps()
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
export const featureEvents = eventGroup({
source: 'Feature Name',
events: {
// Event without payload
opened: type<void>(),
// Events with payloads
itemSelected: type<number>(),
queryChanged: type<string>(),
dataLoaded: type<DataType[]>(),
},
});
4. Computed Signals with withComputed
Use withComputed for read-only derived values that don't need to be updated via patchState:
import { computed } from '@angular/core';
import { withComputed } from '@ngrx/signals';
withComputed(({ items, filter }) => ({
// Simple computed
itemsCount: computed(() => items().length),
// Computed with dependencies
filteredItems: computed(() => {
const query = filter.query().toLowerCase();
return items().filter(item =>
item.name.toLowerCase().includes(query)
);
}),
// Can return functions (auto-wrapped in computed)
hasItems: () => items().length > 0,
}))
5. Signal Dependencies with withLinkedState
CRITICAL: Use withLinkedState when a signal depends on another signal AND needs to be updatable by dispatching events
Decision Tree: withComputed vs withLinkedState
Does the value need to be UPDATED by dispatching events?
│
├─ YES → Use withLinkedState
│ │
│ └─ Does it need previous value or complex logic?
│ │
│ ├─ YES → Use explicit linking with linkedSignal()
│ └─ NO → Use implicit linking with computation function
│
└─ NO → Use withComputed
Implicit Linking (Simple Derivation)
Use when the linked state is a simple derivation:
import { withLinkedState } from '@ngrx/signals';
withLinkedState(({ options, items }) => ({
// Automatically updates when 'options' changes
selectedOption: () => options()[0] ?? undefined,
// Depends on multiple signals
selectedItem: () => {
const selectedId = options()[0];
return items().find(item => item.id === selectedId);
},
}))
// Later you can update it by dispatching an event:
// dispatcher.dispatch(featureEvents.optionChanged(newValue))
Explicit Linking (Complex Computation with Previous Value)
Use when you need to preserve previous state or complex logic:
import { linkedSignal } from '@angular/core';
import { withLinkedState } from '@ngrx/signals';
withLinkedState(({ options }) => ({
selectedOption: linkedSignal<Option[], Option>({
source: options,
computation: (newOptions, previous) => {
// Preserve selection if the option still exists
const option = newOptions.find(
(o) => o.id === previous?.value.id
);
// Otherwise, select the first one
return option ?? newOptions[0];
},
}),
}))
Complete Store Structure
Step-by-Step Process
1. Define Domain Models (if needed)
// models/feature.model.ts
export interface Item {
id: number;
name: string;
status: 'active' | 'inactive';
}
2. Define Events
// store/feature.events.ts
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Item } from '../models/feature.model';
export const featureEvents = eventGroup({
source: 'Feature',
events: {
pageOpened: type<void>(),
queryChanged: type<string>(),
statusChanged: type<'all' | 'active' | 'inactive'>(),
itemSelected: type<number>(),
},
});
export const featureApiEvents = eventGroup({
source: 'Feature API',
events: {
loadSuccess: type<Item[]>(),
loadFailure: type<string>(),
},
});
3. Create Store in store/index.ts
// store/index.ts
import { computed, inject } from '@angular/core';
import { linkedSignal } from '@angular/core';
import {
signalStore,
withState,
withLinkedState,
withComputed,
withMethods,
withHooks,
} from '@ngrx/signals';
import {
Events,
Dispatcher,
on,
withReducer,
withEventHandlers,
} from '@ngrx/signals/events';
import { mapResponse } from '@ngrx/operators';
import { switchMap, tap } from 'rxjs';
import { Item } from '../models/feature.model';
import { FeatureService } from '../services/feature.service';
import { featureEvents, featureApiEvents } from './feature.events';
// State interface
export type FeatureState = {
items: Item[];
isLoading: boolean;
error: string | null;
filter: {
query: string;
status: 'all' | 'active' | 'inactive';
};
};
// Initial state
const initialState: FeatureState = {
items: [],
isLoading: false,
error: null,
filter: { query: '', status: 'all' },
};
// Signal Store
export const FeatureStore = signalStore(
// 1. Base state
withState(initialState),
// 2. Linked state (depends on base state, updatable)
withLinkedState(({ items }) => ({
// Implicit linking - simple derivation
selectedItem: () => items()[0] ?? null,
})),
// 3. Computed (read-only derived values)
withComputed(({ items, filter, selectedItem }) => ({
itemsCount: computed(() => items().length),
filteredItems: computed(() => {
const query = filter.query().toLowerCase();
const status = filter.status();
return items().filter(item => {
const matchesQuery = item.name.toLowerCase().includes(query);
const matchesStatus = status === 'all' || item.status === status;
return matchesQuery && matchesStatus;
});
}),
hasSelection: computed(() => selectedItem() !== null),
})),
// 4. State transitions (reducers)
withReducer(
on(featureEvents.queryChanged, ({ payload: query }) => ({
filter: { query, status: 'all' },
})),
on(featureEvents.statusChanged, ({ payload: status }) => ({
filter: (state) => ({ ...state.filter, status }),
})),
on(featureEvents.pageOpened, () => ({ isLoading: true })),
on(featureApiEvents.loadSuccess, ({ payload: items }) => ({
items,
isLoading: false,
error: null,
})),
on(featureApiEvents.loadFailure, ({ payload: error }) => ({
isLoading: false,
error,
}))
),
// 5. Side effects (event handlers)
withEventHandlers(
(store, events = inject(Events), service = inject(FeatureService)) => ({
loadItems$: events
.on(featureEvents.pageOpened, featureEvents.queryChanged)
.pipe(
switchMap(() =>
service.getItems(store.filter.query()).pipe(
mapResponse({
next: (items) => featureApiEvents.loadSuccess(items),
error: (error: { message: string }) =>
featureApiEvents.loadFailure(error.message),
})
)
)
),
logError$: events
.on(featureApiEvents.loadFailure)
.pipe(tap(({ payload }) => console.error(payload))),
})
),
// 6. Methods (optional, for dispatching events imperatively)
withMethods((store, dispatcher = inject(Dispatcher)) => ({
selectItemById(id: number): void {
// Dispatch event instead of using patchState directly
dispatcher.dispatch(featureEvents.itemSelected(id));
},
})),
// 7. Lifecycle hooks
withHooks({
onInit(store, dispatcher = inject(Dispatcher)) {
console.log('Store initialized');
dispatcher.dispatch(featureEvents.pageOpened());
},
onDestroy(store) {
console.log('Store destroyed', store.itemsCount());
},
})
);
4. Component Integration
// feature.component.ts
import { Component, inject } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { FeatureStore } from './store'; // Import from store folder
import { featureEvents } from './store/feature.events';
@Component({
selector: 'app-feature',
template: `
<h1>Items ({{ store.itemsCount() }})</h1>
<input
type="text"
[value]="store.filter.query()"
(input)="dispatch.queryChanged($any($event.target).value)"
/>
<select
[value]="store.filter.status()"
(change)="dispatch.statusChanged($any($event.target).value)"
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
@if (store.isLoading()) {
<p>Loading...</p>
}
@if (store.error()) {
<p class="error">{{ store.error() }}</p>
}
@for (item of store.filteredItems(); track item.id) {
<div
class="item"
[class.selected]="store.selectedItem()?.id === item.id"
(click)="dispatch.itemSelected(item.id)"
>
{{ item.name }} - {{ item.status }}
</div>
}
`,
providers: [FeatureStore],
})
export class FeatureComponent {
readonly store = inject(FeatureStore);
readonly dispatch = injectDispatch(featureEvents);
}
File Structure
CRITICAL: Always organize stores in a dedicated store/ folder per feature
feature/
├── models/
│ └── feature.model.ts # Domain models (entities, DTOs)
├── services/
│ └── feature.service.ts # API services
├── store/
│ ├── index.ts # State interface, initial state, signal store (exported)
│ └── feature.events.ts # Event groups
└── feature.component.ts # Component using store
Store Organization Rules
- ✅ ALWAYS create a
store/folder inside the feature folder - ✅ ALWAYS export the store from
store/index.ts - ✅ State interface goes in
store/index.ts(not in models/) - ✅ Events go in
store/feature.events.ts(dedicated file) - ✅ Domain models (entities, DTOs) go in
models/folder - ✅ Import the store as:
import { FeatureStore } from './store';
Critical Rules
File Organization
- ✅ ALWAYS create a
store/folder inside the feature folder - ✅ ALWAYS put state interface, initial state, and signal store in
store/index.ts - ✅ ALWAYS put events in
store/feature.events.ts(separate file) - ✅ ALWAYS export the store from
store/index.tsfor clean imports - ✅ Import store as:
import { FeatureStore } from './store'; - ✅ Import events as:
import { featureEvents } from './store/feature.events'; - ❌ NEVER put state types in the
models/folder (only domain models go there)
Event Definitions
- ✅ ALWAYS use
type<>()for defining event payloads - ✅ Use
type<void>()for events without payload - ❌ NEVER use
payload<>(),emptyPayload(),props(), oremptyProps() - ✅ Group related events with
eventGroup - ✅ Use descriptive event names:
queryChanged, notchange
Linked State vs Computed
- ✅ Use
withLinkedStatewhen the value needs to be updatable by dispatching events - ✅ Use
withComputedfor read-only derived values - ✅ Use implicit linking for simple derivations
- ✅ Use explicit linking (
linkedSignal) when you need:- Access to previous value
- Complex computation logic
- Multi-source dependencies with custom logic
Store Composition Order
The order matters! Always follow this sequence:
signalStore(
withState(/* ... */), // 1. Base state
withLinkedState(/* ... */), // 2. Linked state (depends on base)
withComputed(/* ... */), // 3. Computed (read-only derived)
withReducer(/* ... */), // 4. State transitions
withEventHandlers(/* ... */), // 5. Side effects
withMethods(/* ... */), // 6. Imperative methods (optional)
withHooks(/* ... */) // 7. Lifecycle hooks
)
Event Dispatching
- ✅ Prefer
injectDispatch()over manualDispatcherinjection - ✅ Use events for most state changes (declarative)
- ✅ Use methods only when imperative control is needed
- ✅ Dispatch events from components, not stores
Type Safety
- ✅ Always define explicit types for state
- ✅ Always define explicit types for event payloads
- ✅ Use TypeScript strict mode
- ✅ Avoid
any- use proper types
Verification Checklist
Before completing store creation/modification:
File Structure:
-
store/folder exists inside feature folder -
store/index.tscontains state interface, initial state, and signal store -
store/feature.events.tscontains all event definitions - Store is exported from
store/index.ts - Component imports from
./store(not a specific file)
Type Safety:
- All state properties have explicit types
- All events use
type<>()syntax - No
anytypes used
Store Composition:
- Dependencies between signals use
withLinkedState - Read-only derived values use
withComputed - Event handlers perform side effects correctly
Integration:
- Store is provided at component level
- Events are dispatched using
injectDispatch()
Code Quality:
- Code builds without errors
- No unused imports or variables
Common Patterns
Pattern 1: List with Selection
withState({ items: [], selectedId: null as number | null }),
withLinkedState(({ items, selectedId }) => ({
selectedItem: () => {
const id = selectedId();
return id ? items().find(i => i.id === id) ?? null : null;
},
}))
Pattern 2: Filtered and Sorted List
withComputed(({ items, filter }) => ({
filteredAndSorted: computed(() => {
const filtered = items().filter(/* filter logic */);
return filtered.sort(/* sort logic */);
}),
}))
Pattern 3: Loading State Management
withReducer(
on(dataEvents.loadStarted, () => ({ isLoading: true, error: null })),
on(dataEvents.loadSuccess, ({ payload }) => ({
data: payload,
isLoading: false,
error: null,
})),
on(dataEvents.loadFailure, ({ payload }) => ({
isLoading: false,
error: payload,
}))
)
References
- NgRx Signals Documentation: https://ngrx.io/guide/signals
- Signal Store: https://ngrx.io/guide/signals/signal-store
- Events Plugin: https://ngrx.io/guide/signals/signal-store/events
- Linked State: https://ngrx.io/guide/signals/signal-store/linked-state