Lesson 5
SignalStore: Your First Store
The Core Idea
withState, withComputed, withMethods - like building blocks. Each feature can see everything that came before it. The result is an injectable class with signals for state and methods for updates. No actions, no reducers, no selectors - just functions composing functions.
If you've used classic NgRx, you know the ceremony: define actions, write reducers, create selectors, wire effects, register in a module. SignalStore replaces all of that with a single signalStore() function call. It's not a different version of the same thing - it's a fundamentally different architecture that happens to solve the same problem.
Installation
npm install @ngrx/signals
That's it. No StoreModule.forRoot(), no EffectsModule, no app-level wiring. SignalStore is standalone - each store is an independent injectable.
Your First Store
Let's build a store for the Policy Dashboard's filter state - tracking which filters the user has applied to the policy list:
import { computed } from '@angular/core';
import {
signalStore,
withState,
withComputed,
withMethods,
patchState,
} from '@ngrx/signals';
export type PolicyStatus = 'all' | 'active' | 'expired' | 'pending';
export type SortBy = 'premium' | 'holder' | 'expiry';
export type SortOrder = 'asc' | 'desc';
export interface PolicyFilterState {
query: string;
status: PolicyStatus;
sortBy: SortBy;
sortOrder: SortOrder;
}
const initialState: PolicyFilterState = {
query: '',
status: 'all',
sortBy: 'holder',
sortOrder: 'asc',
};
export const PolicyFilterStore = signalStore(
withState(initialState),
withComputed(({ query, status }) => ({
hasActiveFilters: computed(() => query() !== '' || status() !== 'all'),
})),
withMethods((store) => ({
updateQuery(query: string): void {
patchState(store, { query });
},
updateStatus(status: PolicyStatus): void {
patchState(store, { status });
},
updateSort(sortBy: SortBy): void {
patchState(store, (state) => ({
sortBy,
sortOrder: (state.sortBy === sortBy && state.sortOrder === 'asc' ? 'desc' : 'asc') as SortOrder,
}));
},
resetFilters(): void {
patchState(store, initialState);
},
}))
);
That's a complete store. This is a plain TypeScript file - no decorator, no module, no component. signalStore() returns an injectable class that Angular's DI system can provide and inject like any service. You'd save this as:
# File: src/app/policies/policy-filter.store.ts
# Convention: feature-name.store.ts, alongside the components that use it
And consume it in a component by injecting it:
import { PolicyFilterStore } from './policy-filter.store';
@Component({
providers: [PolicyFilterStore], // provide here, or use providedIn: 'root' in the store
template: `...`
})
export class PolicySearchPage {
readonly store = inject(PolicyFilterStore); // inject like any service
}
There's no registration step beyond this. No StoreModule.forRoot(), no forFeature(). The store is just an injectable - Angular's standard DI handles the rest. We'll cover provisioning strategies (root vs component-level) in detail below.
Sharing State Between Components
An immediate question: if multiple components inject this store, do they all see the same state? Yes - they share the same instance. When one component updates the store, the others react automatically because they're reading the same signal references. No event bus, no manual subscription, no shared service pattern.
How they share depends on how the store is provisioned:
providedIn: 'root'(passed as the first argument tosignalStore()) - global singleton. Every component in the app that injects it gets the same instance. Noprovidersarray needed anywhere.- Component
providersarray - the providing component and all its children share one instance. Other parts of the app get nothing (unless they also provide it, which creates a separate instance).
// Global singleton - first argument to signalStore()
export const AuthStore = signalStore(
{ providedIn: 'root' }, // <-- this is how, no decorator needed
withState({ user: null as User | null })
);
// Any component, anywhere - just inject:
readonly auth = inject(AuthStore);
// vs. component-scoped - no providedIn, must be in providers:
export const FilterStore = signalStore(
withState({ query: '' })
);
Here's the component-level approach - a parent provides the store, its two children share it:
// A parent provides the store - its children share the instance
@Component({
selector: 'app-policy-page',
providers: [PolicyFilterStore],
template: `
<app-filter-bar />
<app-policy-results />
`
})
export class PolicyPage {}
// Child 1: updates the store
@Component({
selector: 'app-filter-bar',
template: `
<input
[value]="store.query()"
(input)="store.updateQuery($event.target.value)"
/>
`
})
export class FilterBarComponent {
readonly store = inject(PolicyFilterStore); // same instance as sibling
}
// Child 2: reads from the same store - updates automatically
@Component({
selector: 'app-policy-results',
template: `
<p>Showing results for: "{{ store.query() }}"</p>
@if (store.hasActiveFilters()) {
<p>Filters active</p>
}
`
})
export class PolicyResultsComponent {
readonly store = inject(PolicyFilterStore); // same instance as sibling
}
FilterBarComponent calls store.updateQuery(). PolicyResultsComponent's template reads store.query() - it re-renders instantly because it's the same signal. No wiring needed. This is Angular DI doing what it already does - the store is just an injectable service whose properties happen to be signals.
All examples in this lesson are standalone components (the default since Angular 19 - no standalone: true needed). The DI mechanics are identical to module-based components: providers on a standalone component creates an injector at that level, and children inherit it. SignalStore doesn't care whether your components use modules or standalone.
Shared Instance vs Separate Instances
Contrast these two scenarios:
Shared: siblings share the parent's store
// Parent provides once - children share ONE instance
@Component({
providers: [PolicyFilterStore],
template: `
<app-filter-bar />
<app-policy-results />
`
})
export class PolicyPage {}
// Both children inject the SAME store instance
// FilterBar updates query -> PolicyResults sees the change instantly
Separate: each component provides its own instance
// Each component provides its OWN instance - they don't share state
@Component({
providers: [PolicyFilterStore],
template: `<input (input)="store.updateQuery($event.target.value)" />`
})
export class SearchWidgetA {
readonly store = inject(PolicyFilterStore); // instance A
}
@Component({
providers: [PolicyFilterStore],
template: `<input (input)="store.updateQuery($event.target.value)" />`
})
export class SearchWidgetB {
readonly store = inject(PolicyFilterStore); // instance B - completely independent
}
// Updating store in SearchWidgetA has NO effect on SearchWidgetB.
// They're separate instances with separate state.
The rule: whoever lists the store in their providers array creates a new instance. Components that don't provide it themselves walk up the injector tree until they find an ancestor that did. That's how siblings share - neither provides it, so both find the parent's instance.
"Child" here means the component tree (the rendering hierarchy in templates), not the file system. FilterBarComponent is a child of PolicyPage because it's rendered via <app-filter-bar /> in the parent's template. It could live in a completely different file, folder, or feature area - what matters is where it appears in the rendered component tree. That tree determines DI resolution.
Now let's break down each piece.
withState: Defining State
withState defines the store's state shape and initial values. It creates a deeply nested signal for each property - you read state by calling the property as a function:
const store = inject(PolicyFilterStore);
store.query(); // '' (Signal<string>)
store.status(); // 'all' (Signal<'all' | 'active' | 'expired' | 'pending'>)
store.sortBy(); // 'holder'
store.sortOrder(); // 'asc'
Each state property becomes a signal on the store instance. You read them with () in templates and component logic - exactly like any other signal.
For nested objects, SignalStore creates deep signals - each nested property gets its own signal:
// Nested state
const store = signalStore(
withState({
filter: { query: '', order: 'asc' as const },
pagination: { page: 1, pageSize: 25 },
})
);
// The parent is a signal (returns the whole object)
store.filter(); // { query: '', order: 'asc' }
// Its properties are also signals (return individual values)
store.filter.query(); // ''
store.filter.order(); // 'asc'
store.pagination.page(); // 1
store.filter is both a signal you can invoke (to get the whole object) and a container with signal properties on it. This means Angular tracks dependencies at the leaf level - if you only read store.filter.query() in a template and order changes, your template doesn't re-render. Only the specific signals you read matter for change detection.
signal({ query: '', order: 'asc' }) does not create nested signals on its properties - you'd get the whole object back and any change re-notifies. Deep signals are specific to SignalStore's withState and signalState, which create this nested structure for you automatically.
JSON.parse(JSON.stringify(state)) would lose information, don't put it in state. This is the same constraint as classic NgRx - state is data, not behaviour.
patchState will happily accept whatever TypeScript allows. The classic footgun: storing a raw HttpErrorResponse (which has methods, circular references, and non-serialisable properties) when your state type says error: unknown. It goes in without complaint, then breaks DevTools or getState() later. Always extract the serialisable parts (message, status code) before storing errors.
State Factory
If your initial state depends on injected values, pass a factory function instead of an object:
import { InjectionToken } from '@angular/core';
const DEFAULT_PAGE_SIZE = new InjectionToken<number>('DefaultPageSize', {
factory: () => 25,
});
export const PaginationStore = signalStore(
withState(() => ({
page: 1,
pageSize: inject(DEFAULT_PAGE_SIZE),
}))
);
This is a relatively uncommon pattern - most stores just hardcode their initial values. You'd reach for it when configuration needs to vary between environments, be overridden in tests, or differ between components (e.g., different parts of the app need different page sizes via the DI hierarchy). For a constant that never changes, a plain const is simpler and better - no token needed.
inject(PolicyService) works because PolicyService is a unique class reference. But when you want to inject a plain value (a number, string, or config object), there's no class to use as a key. InjectionToken creates that key.
Breaking down new InjectionToken<number>('DefaultPageSize', { factory: () => 25 }):
<number>- the type of value this token represents. When youinject(DEFAULT_PAGE_SIZE), TypeScript knows the result is anumber.'DefaultPageSize'- a description string for debugging (shows up in error messages). Not used for lookup - two tokens with the same string are still different tokens.{ factory: () => 25 }- optional second argument. If provided, Angular uses this factory to create the value when no explicit provider is configured. It also implicitly registers the token at root level (likeprovidedIn: 'root'for services). Without this, you'd need to add{ provide: DEFAULT_PAGE_SIZE, useValue: 25 }to aprovidersarray somewhere.
If you've never needed one: that's normal. You only need InjectionToken when injecting something that isn't a class - configuration values, feature flags, or abstract interfaces you want to swap in tests.
patchState: Updating State
State is updated exclusively through patchState. It works like spreading a partial object over the current state - you only specify what changed:
import { patchState } from '@ngrx/signals';
// Partial object - merge these properties into current state
patchState(store, { query: 'flood' });
// Updater function - receives current state, returns partial update
patchState(store, (state) => ({
sortOrder: state.sortOrder === 'asc' ? 'desc' : 'asc',
}));
// Multiple updates in one call
patchState(store, { query: '' }, { status: 'all' });
Key characteristics:
- Immutable: patchState creates a new state reference internally. The signals update and downstream computeds re-evaluate. You never mutate state directly.
- Type-safe: You can only patch properties that exist in the state type. TypeScript catches invalid keys at compile time.
- Batched: Multiple
patchStatecalls within a single synchronous execution batch signal updates - no intermediate renders.
store.query.set('flood') won't work - state signals are read-only from outside the store. All writes go through patchState inside withMethods. This is intentional: it keeps state changes traceable and centralised.
Fixed State Shape
The state shape is locked in at definition time by withState(initialState). You cannot add new properties later - patchState only accepts keys that already exist in the state type:
// State shape defined once
withState({
filter: { query: '', order: 'asc' as const },
policies: [] as Policy[],
})
// Later:
patchState(store, { policies: newPolicies }); // works - existing key
patchState(store, { newProp: 'foo' }); // TypeScript error - key doesn't exist
// Nested properties are fixed too:
patchState(store, {
filter: { query: 'flood', order: 'desc' } // works
});
patchState(store, {
filter: { query: 'flood', order: 'desc', limit: 10 } // TypeScript error - limit doesn't exist
});
This applies at every depth - the entire tree is typed from the initial state. If you need a property, define it upfront (even if it starts as null, [], or another empty value). Same constraint as classic NgRx reducers.
Reusable State Updaters
For updates you use in multiple places, extract them as named functions:
import { PartialStateUpdater } from '@ngrx/signals';
// Reusable updater - can be used in any store with these properties
function setLoading(): PartialStateUpdater<{ isLoading: boolean }> {
return () => ({ isLoading: true });
}
function setLoaded(): PartialStateUpdater<{ isLoading: boolean }> {
return () => ({ isLoading: false });
}
// Usage inside withMethods
withMethods((store) => ({
async loadPolicies(): Promise<void> {
patchState(store, setLoading());
const policies = await this.policyService.getAll();
patchState(store, { policies }, setLoaded());
},
}))
withComputed: Derived State
withComputed adds derived signals to your store - the equivalent of NgRx selectors, but using Angular's computed(). The factory function receives the store (everything defined before it), and you return an object of computed signals:
import { computed } from '@angular/core';
export const PolicyListStore = signalStore(
withState({
policies: [] as Policy[],
filter: { query: '', status: 'all' as PolicyStatus },
}),
withComputed(({ policies, filter }) => ({
filteredPolicies: computed(() => {
const q = filter.query().toLowerCase();
const status = filter.status();
return policies().filter(p =>
(status === 'all' || p.status === status) &&
(q === '' || p.holder.toLowerCase().includes(q))
);
}),
totalCount: computed(() => policies().length),
activeCount: computed(() =>
policies().filter(p => p.status === 'active').length
),
}))
);
Computed signals are lazy and cached - they only re-evaluate when their dependencies change. If filter.query() changes but policies() doesn't, only filteredPolicies re-evaluates (since it reads both). totalCount and activeCount stay cached.
createSelector() and memoisation. SignalStore's withComputed achieves the same thing - derived, memoised, composable state slices - but using Angular's native computed() instead of a custom memoisation library.
withMethods: Actions and Logic
withMethods defines the public API of your store - the functions that components call to trigger state changes. The factory receives the store instance (state signals + computed signals + any previously defined methods) and can also inject services:
export const PolicyListStore = signalStore(
withState({ policies: [] as Policy[], isLoading: false }),
withComputed(/* ... */),
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
const policies = await policyService.getAll();
patchState(store, { policies, isLoading: false });
},
removePolicy(id: string): void {
patchState(store, (state) => ({
policies: state.policies.filter(p => p.id !== id),
}));
},
updatePolicy(updated: Policy): void {
patchState(store, (state) => ({
policies: state.policies.map(p =>
p.id === updated.id ? updated : p
),
}));
},
}))
);
Key points:
- Dependency injection: Services are injected as default parameters in the factory function. No constructor, no
@Inject- justinject()in the parameter list. - Async methods: Methods can be async. Call
patchStatebefore and after the async operation to manage loading state. - Composition order matters:
withMethodscan access everything defined above it. If you need a method to call another method, define them in separatewithMethodsblocks (the second can see the first).
Dependency Injection in Detail
The DI pattern can be easy to miss - it looks like a regular default parameter, but it's Angular's inject() running inside the factory:
withMethods((
store,
policyService = inject(PolicyService),
notificationService = inject(NotificationService),
router = inject(Router)
) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
const policies = await policyService.getAll();
patchState(store, { policies, isLoading: false });
},
async deletePolicy(id: string): Promise<void> {
await policyService.delete(id);
patchState(store, (state) => ({
policies: state.policies.filter(p => p.id !== id),
}));
notificationService.show('Policy deleted');
},
navigateToPolicy(id: string): void {
router.navigate(['/policies', id]);
},
}))
This works because signalStore() creates the store inside an injection context (same as a constructor). The factory function's default parameters are evaluated at that moment, so inject() has access to the DI system. You can inject as many services as you need - they're just additional parameters after store.
Async Methods
Methods can be async. The pattern: set loading state before the await, update state after it resolves. The store's signals update reactively at each patchState call, so the template shows a spinner during the gap:
// In the store:
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true }); // template shows spinner
const policies = await policyService.getAll(); // HTTP call
patchState(store, { policies, isLoading: false });// template shows data
},
}))
// In the component - just call it:
@Component({
providers: [PolicyListStore],
template: `
@if (store.isLoading()) {
<app-spinner />
} @else {
@for (policy of store.policies(); track policy.id) {
<app-policy-card [policy]="policy" />
}
}
`
})
export class PolicyListPage {
readonly store = inject(PolicyListStore);
constructor() {
this.store.loadPolicies(); // fire and forget - no subscribe needed
}
}
No observable, no subscribe(), no async pipe. The method is fire-and-forget from the component's perspective - the store manages its own loading state internally.
Working with Observable-Returning Services
Most Angular services return observables (because HttpClient does). To use them inside an async method, convert with firstValueFrom:
import { firstValueFrom } from 'rxjs';
import { retry, timeout } from 'rxjs';
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
// policyService.getAll() returns Observable<Policy[]>
// firstValueFrom converts it to a Promise for await
const policies = await firstValueFrom(
policyService.getAll().pipe(
retry({ count: 2, delay: 1000 }),
timeout(5000)
)
);
patchState(store, { policies, isLoading: false });
},
}))
firstValueFrom takes the first emission from the observable and returns it as a Promise. For single-emission observables like HTTP calls, this is a lossless conversion. You can still pipe RxJS operators before it - retry, timeout, map, catchError all work as normal.
The limitation: operators that depend on multiple emissions over time (debounceTime, switchMap, distinctUntilChanged) need a long-lived subscription. firstValueFrom takes one value and unsubscribes. For those patterns, you'd use rxMethod instead (covered in Lesson 7).
Layering withMethods
You can use withMethods multiple times. Each layer can see the methods defined in previous layers:
export const PolicyStore = signalStore(
withState({ policies: [] as Policy[], isLoading: false }),
// First layer: low-level state operations
withMethods((store) => ({
setPolicies(policies: Policy[]): void {
patchState(store, { policies, isLoading: false });
},
setLoading(): void {
patchState(store, { isLoading: true });
},
})),
// Second layer: higher-level operations that use the first layer
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
store.setLoading();
const policies = await policyService.getAll();
store.setPolicies(policies);
},
}))
);
Provisioning: Where Does the Store Live?
SignalStore supports two provisioning strategies - same as any Angular service:
Global (providedIn: 'root')
providedIn: 'root' tells Angular: "create one instance of this and make it available app-wide without needing to list it in any providers array." You just inject() it wherever you need it, and Angular gives you the same singleton instance every time.
If you've used classic NgRx, you may not have used providedIn: 'root' much - because the global store itself is already registered through StoreModule.forRoot(). With SignalStore there's no module registration step. Each store is an independent injectable, so providedIn: 'root' is how you say "this is a global singleton."
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState({ user: null as User | null, isAuthenticated: false }),
withComputed(/* ... */),
withMethods(/* ... */)
);
// Any component, anywhere - no providers array needed:
export class HeaderComponent {
readonly auth = inject(AuthStore); // same instance every time
}
Component-Level
If you omit providedIn: 'root', the store must be listed in a component's providers array to be injectable. Each component that provides it gets its own fresh instance, destroyed when the component is destroyed. Child components of that provider share the same instance via normal DI resolution.
// Store definition - no providedIn
export const PolicySearchStore = signalStore(
withState(initialState),
withComputed(/* ... */),
withMethods(/* ... */)
);
// Component provides it
@Component({
providers: [PolicySearchStore],
template: `
<input (input)="store.updateQuery($event.target.value)" />
@for (policy of store.filteredPolicies(); track policy.id) {
<app-policy-card [policy]="policy" />
}
`
})
export class PolicySearchPage {
readonly store = inject(PolicySearchStore);
}
- providedIn: 'root' - auth state, user preferences, app-wide caches. Lives for the app's lifetime. Every component that injects it shares the same instance and the same state.
- Component providers - page-specific data, search results, form state. Created fresh on navigation, destroyed on leave. No stale state between visits. Child components of the provider share the parent's instance (standard Angular DI resolution).
In classic NgRx, everything lives in a single global store - which nudges you toward treating all state as global. SignalStore gives you granular control: global state is a singleton, page-scoped state gets a fresh instance per visit. Both are common patterns in a real app.
withHooks: Lifecycle
Stores can hook into their own lifecycle with withHooks:
import { withHooks } from '@ngrx/signals';
export const PolicyListStore = signalStore(
{ providedIn: 'root' },
withState({ policies: [] as Policy[], isLoading: false }),
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
const policies = await policyService.getAll();
patchState(store, { policies, isLoading: false });
},
})),
withHooks({
onInit(store) {
// Auto-load policies when the store is created
store.loadPolicies();
},
onDestroy(store) {
console.log('PolicyListStore destroyed', store.policies().length, 'policies');
},
})
);
onInit fires when the store is first injected. onDestroy fires when its injector is destroyed (component unmount for component-level stores, app shutdown for root stores). You can use inject() inside the hooks since they run in an injection context.
Factory Form with Dependencies
If your hooks need injected dependencies, use the factory form:
withHooks((store) => {
const logger = inject(Logger);
return {
onInit() {
logger.info('Store initialised');
store.loadPolicies();
},
onDestroy() {
logger.info('Store destroyed');
},
};
})
signalState: The Lightweight Alternative
Not every piece of state needs a full store. For simple, local state in a component or service, signalState gives you the same deep-signal behaviour and patchState without the store scaffolding:
import { signalState, patchState } from '@ngrx/signals';
@Component({
template: `
@if (state.isExpanded()) {
<div class="panel-content">{{ state.content() }}</div>
}
<button (click)="toggle()">
{{ state.isExpanded() ? 'Collapse' : 'Expand' }}
</button>
`
})
export class CollapsiblePanel {
readonly state = signalState({
isExpanded: false,
content: '',
});
toggle(): void {
patchState(this.state, (s) => ({ isExpanded: !s.isExpanded }));
}
}
signalState gives you:
- Deep signals on nested properties (same as
withState) patchStatefor immutable updates- A fixed, typed state shape
It does not give you:
- No
withComputed- use Angular'scomputed()directly alongside it - No
withMethods- write methods on the component or service class - No DI (it's not injectable - it's just a local value)
- No lifecycle hooks
- No feature composition
signalState in a Service
You can also use signalState inside an injectable service for state that multiple components share but doesn't need full store composition:
@Injectable({ providedIn: 'root' })
export class ToastService {
readonly state = signalState({
messages: [] as ToastMessage[],
position: 'top-right' as 'top-right' | 'bottom-right',
});
// You write methods on the class - not inside the state
show(message: string, type: 'success' | 'error' = 'success'): void {
patchState(this.state, (s) => ({
messages: [...s.messages, { id: crypto.randomUUID(), message, type }],
}));
}
dismiss(id: string): void {
patchState(this.state, (s) => ({
messages: s.messages.filter(m => m.id !== id),
}));
}
}
Why Not Just Use signal()?
For a single primitive value (signal(false), signal(0)), plain signal() is fine. signalState earns its keep when you have an object with multiple properties that you want to update partially and read granularly:
// Plain signal - have to spread manually, no deep signals
const state = signal({ query: '', page: 1, isLoading: false });
state.update(s => ({ ...s, query: 'flood' })); // manual spread
state().query; // not a signal - just a value read
// signalState - patchState handles immutability, deep signals for free
const state = signalState({ query: '', page: 1, isLoading: false });
patchState(state, { query: 'flood' }); // partial update, no spreading
state.query(); // a signal - granular reactivity
- Plain
signal()- single values (a boolean toggle, a counter, a selected ID). signalState- local object state with multiple properties you want to patch partially and read granularly. No shared API needed.signalStore- shared state with business logic, computed derivations, service injection, lifecycle hooks, and a public API for consumers.
Consuming a Store in Components
A complete example - the Policy Search page with its store:
@Component({
selector: 'app-policy-search',
providers: [PolicyFilterStore],
template: `
<div class="search-bar">
<input
[value]="store.query()"
(input)="store.updateQuery($event.target.value)"
placeholder="Search policies..."
/>
<select
[value]="store.status()"
(change)="store.updateStatus($event.target.value)">
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="expired">Expired</option>
<option value="pending">Pending</option>
</select>
@if (store.hasActiveFilters()) {
<button (click)="store.resetFilters()">Clear Filters</button>
}
</div>
`
})
export class PolicySearchComponent {
readonly store = inject(PolicyFilterStore);
}
Notice:
- State signals read directly in the template:
store.query(),store.status() - Computed signals used the same way:
store.hasActiveFilters() - Methods called directly from event handlers:
store.updateQuery(...) - No
asyncpipe, no subscription management, nongOnDestroy
Classic NgRx Comparison
The same feature in classic NgRx vs SignalStore:
Classic NgRx
// actions.ts
export const updateQuery = createAction('[Filters] Update Query', props<{ query: string }>());
export const updateStatus = createAction('[Filters] Update Status', props<{ status: string }>());
export const resetFilters = createAction('[Filters] Reset');
// reducer.ts
export const filterReducer = createReducer(
initialState,
on(updateQuery, (state, { query }) => ({ ...state, query })),
on(updateStatus, (state, { status }) => ({ ...state, status })),
on(resetFilters, () => initialState)
);
// selectors.ts
export const selectQuery = createSelector(selectFilterState, s => s.query);
export const selectStatus = createSelector(selectFilterState, s => s.status);
export const selectHasActiveFilters = createSelector(
selectQuery, selectStatus,
(query, status) => query !== '' || status !== 'all'
);
// component.ts
this.store.dispatch(updateQuery({ query: 'flood' }));
this.query$ = this.store.select(selectQuery);
SignalStore
// policy-filter.store.ts - everything in one file
export const PolicyFilterStore = signalStore(
withState(initialState),
withComputed(({ query, status }) => ({
hasActiveFilters: computed(() => query() !== '' || status() !== 'all'),
})),
withMethods((store) => ({
updateQuery(query: string): void { patchState(store, { query }); },
updateStatus(status: string): void { patchState(store, { status }); },
resetFilters(): void { patchState(store, initialState); },
}))
);
// component.ts
this.store.updateQuery('flood');
this.store.query(); // direct signal read
Same behaviour. A fraction of the code. No indirection through actions and selectors. The trade-off: you lose the action log (Redux DevTools won't show named events). We'll address that in Lesson 9 and Lesson 11.
Private Store Members
Prefix any state property, computed signal, or method with _ to make it private - inaccessible from outside the store but available to later features in the composition chain. This isn't just naming convention - SignalStore strips underscore-prefixed members from the store's public type. TypeScript will error if you try to access them from a component:
export const PolicyStore = signalStore(
withState({
_rawPolicies: [] as Policy[], // private - internal only
isLoading: false, // public
}),
withComputed(({ _rawPolicies }) => ({
// Private state used internally, public computed exposed
policies: computed(() => _rawPolicies().filter(p => !p.deleted)),
totalCount: computed(() => _rawPolicies().length),
})),
withMethods((store) => ({
// This method uses _rawPolicies internally
_setRawPolicies(policies: Policy[]): void {
patchState(store, { _rawPolicies: policies });
},
// Public method delegates to private
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
const policies = await inject(PolicyService).getAll();
store._setRawPolicies(policies);
patchState(store, { isLoading: false });
},
}))
);
// In a component:
const store = inject(PolicyStore);
store.policies(); // works - public computed
store.isLoading(); // works - public state
store.loadPolicies(); // works - public method
store._rawPolicies(); // TypeScript error - private
store._setRawPolicies(); // TypeScript error - private
Reading the Full State Snapshot
Sometimes you need the entire state as a plain object (logging, debugging, serialisation). Use getState():
import { getState } from '@ngrx/signals';
const store = inject(PolicyFilterStore);
const snapshot = getState(store);
// { query: 'flood', status: 'active', sortBy: 'holder', sortOrder: 'asc' }
console.log(JSON.stringify(snapshot));
The Composition Model
The power of SignalStore is in its composition. Features are applied in order, and each feature can access everything that came before it:
signalStore(
withState(...) // 1. State signals created
withComputed(...) // 2. Can read state signals
withMethods(...) // 3. Can read state + computed, can call patchState
withHooks(...) // 4. Can read state + computed + methods
)
This ordering is not arbitrary - it's a dependency graph. State is the foundation, computeds derive from state, methods act on state and read computeds, hooks orchestrate methods. Later lessons will show how to extract reusable features (Lesson 10) that compose into any store.
signalStore() call. Custom features (covered in Lesson 10) let you extract and reuse patterns like "loading state", "pagination", or "entity management" across stores.
Redux DevTools Integration
Redux DevTools don't work with SignalStore out of the box - there are no actions dispatched, so there's nothing for the extension to log. NgRx provides an official plugin to bridge SignalStore to the DevTools:
import { withDevtools } from '@ngrx/signals/devtools';
export const PolicyFilterStore = signalStore(
withState(initialState),
withComputed(/* ... */),
withMethods(/* ... */),
withDevtools('PolicyFilter') // name shown in DevTools panel
);
This gives you state inspection in the Redux DevTools browser extension - you can see state snapshots whenever they change. You won't get full action replay/time-travel (since there are no named actions), but you can see state history with auto-generated labels. If you add the events/reducers pattern (Lesson 11), you get named events in DevTools too.
Excluding DevTools from Production
withDevtools() is code in your store definition. It shouldn't ship to production - use isDevMode() with a spread to conditionally include it:
import { isDevMode } from '@angular/core';
import { withDevtools } from '@ngrx/signals/devtools';
export const PolicyFilterStore = signalStore(
withState(initialState),
withComputed(/* ... */),
withMethods(/* ... */),
...(isDevMode() ? [withDevtools('PolicyFilter')] : [])
);
isDevMode() returns false in production builds (ng build), so the spread produces an empty array and withDevtools is never called. The build optimizer can then tree-shake the import entirely.
If the spread syntax feels noisy across many stores, wrap it in a utility:
// shared/dev-tools.ts
import { isDevMode } from '@angular/core';
import { withDevtools } from '@ngrx/signals/devtools';
export function withDevtoolsIfDev(name: string) {
return isDevMode() ? [withDevtools(name)] : [];
}
// usage in any store
export const PolicyFilterStore = signalStore(
withState(initialState),
withMethods(/* ... */),
...withDevtoolsIfDev('PolicyFilter')
);
StoreDevtoolsModule.instrument() or Redux's composeWithDevTools() - you always have to opt in from the code.
Quiz
How do you update state in a SignalStore?
What does withComputed replace from classic NgRx?
You have filter state that should reset when the user navigates away from the search page. How should you provision the store?
Try It Yourself
Exercise 1: Build a Counter Store
Create a SignalStore with count state, a doubleCount computed, and increment() / decrement() / reset() methods:
// TODO: Create CounterStore with:
// - state: { count: 0 }
// - computed: doubleCount
// - methods: increment, decrement, reset
import { computed } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
export const CounterStore = signalStore(
withState({ count: 0 }),
withComputed(({ count }) => ({
doubleCount: computed(() => count() * 2),
})),
withMethods((store) => ({
increment(): void {
patchState(store, ({ count }) => ({ count: count + 1 }));
},
decrement(): void {
patchState(store, ({ count }) => ({ count: count - 1 }));
},
reset(): void {
patchState(store, { count: 0 });
},
}))
);
The composition order: state first, then computed (which reads state), then methods (which modify state). Each layer builds on the previous.
Exercise 2: Policy Dashboard Store
Build a store for the Policy Dashboard that holds a list of policies, tracks loading state, provides a computed totalPremium, and has a loadPolicies() method that injects a service:
// Given this service:
@Injectable({ providedIn: 'root' })
export class PolicyService {
async getAll(): Promise<Policy[]> { /* ... */ }
}
// TODO: Build PolicyDashboardStore with:
// - state: { policies: Policy[], isLoading: boolean }
// - computed: totalPremium (sum of all policy premiums)
// - methods: loadPolicies() that injects PolicyService
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
type DashboardState = {
policies: Policy[];
isLoading: boolean;
};
export const PolicyDashboardStore = signalStore(
withState<DashboardState>({ policies: [], isLoading: false }),
withComputed(({ policies }) => ({
totalPremium: computed(() =>
policies().reduce((sum, p) => sum + p.premium, 0)
),
})),
withMethods((store, policyService = inject(PolicyService)) => ({
async loadPolicies(): Promise<void> {
patchState(store, { isLoading: true });
const policies = await policyService.getAll();
patchState(store, { policies, isLoading: false });
},
}))
);
Services are injected as default parameters in the withMethods factory. The inject() call works because the factory runs in an injection context when the store is created.
Exercise 3: signalState for Local Component State
You have a modal component that tracks its own open/closed state and a title. Use signalState (not a full store) to manage this:
// TODO: Use signalState to manage:
// - isOpen: boolean
// - title: string
// Write open(title: string) and close() methods
import { signalState, patchState } from '@ngrx/signals';
@Component({
template: `
@if (state.isOpen()) {
<div class="modal-overlay" (click)="close()">
<div class="modal" (click)="$event.stopPropagation()">
<h2>{{ state.title() }}</h2>
<ng-content />
</div>
</div>
}
`
})
export class ModalComponent {
readonly state = signalState({ isOpen: false, title: '' });
open(title: string): void {
patchState(this.state, { isOpen: true, title });
}
close(): void {
patchState(this.state, { isOpen: false, title: '' });
}
}
signalState is perfect here - simple local state, no need for computed, methods composition, or DI. The component owns and manages it directly.
Your Tangible Win
You can now build a complete SignalStore: define state, derive computed values, expose methods, inject services, and provision the store at the right level. You've replaced the classic NgRx ceremony (actions, reducers, selectors, effects, module registration) with a single composable function. Next lesson, we'll add entity management - the patterns for working with collections of items (add, remove, update, select).