NgRx Signal Store Deep Dive: State, Methods, Entities and Testing [2026]
A service with a few private signals is a fine store, until you have fifteen of them. Each one handles loading flags slightly differently, each re-implements "update the item with this id", and new team members have to learn fifteen small conventions. NgRx Signal Store keeps everything that's good about signals in services, and replaces the conventions with a small set of composable building blocks that every store shares.
This is lesson 14.2 of the Angular Tutorial. Lesson 14.1 placed Signal Store on the state management ladder; this lesson builds one feature by feature. You'll learn signalStore and its features (withState, withComputed, withProps, withMethods, withHooks), updating state with patchState, side effects with rxMethod and signalMethod, entity collections, custom features, where to provide a store, and how to test it.
The mental model #
A Signal Store is a class generated from a list of features. You call signalStore(...) with features in order; each feature can see everything the features before it added. The result is an injectable service.
| Feature | Adds | Runs in injection context? |
|---|---|---|
withState(initial) |
One signal per state property (nested objects become deep signals) | Factory form only |
withComputed(fn) |
Derived signals | Yes |
withProps(fn) |
Any other properties: injected services, observables, constants | Yes |
withMethods(fn) |
Methods, including ones that update state | Yes |
withHooks({ onInit, onDestroy }) |
Code that runs when the store is created or destroyed | Yes |
Install the package with the CLI:
ng add @ngrx/signals@latest
State, computed and methods #
Here's a store for a task list screen:
import { computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
import { TasksService } from './tasks.service';
export interface Task {
id: string;
title: string;
done: boolean;
}
type TasksState = {
tasks: Task[];
loading: boolean;
filter: { query: string; showDone: boolean };
};
const initialState: TasksState = {
tasks: [],
loading: false,
filter: { query: '', showDone: true },
};
export const TasksStore = signalStore(
withState(initialState),
withComputed(({ tasks, filter }) => ({
remaining: computed(() => tasks().filter(t => !t.done).length),
visible: computed(() => {
const q = filter.query().toLowerCase();
return tasks().filter(t => (filter.showDone() || !t.done) && t.title.toLowerCase().includes(q));
}),
})),
withMethods((store, tasksService = inject(TasksService)) => ({
setQuery(query: string): void {
patchState(store, state => ({ filter: { ...state.filter, query } }));
},
toggle(id: string): void {
patchState(store, ({ tasks }) => ({
tasks: tasks.map(t => (t.id === id ? { ...t, done: !t.done } : t)),
}));
},
async load(): Promise<void> {
patchState(store, { loading: true });
const tasks = await tasksService.getAll();
patchState(store, { tasks, loading: false });
},
})),
);
A few things to notice:
- Every state property is a signal.
store.tasks(),store.loading(), and becausefilteris an object, alsostore.filter.query()andstore.filter.showDone(). Nested signals are created lazily. patchStatetakes partial state or an updater function, and you can pass several in one call:patchState(store, a, b). Likesignal.update(), it needs new references; never mutate the current state.- State is protected. Outside the store,
patchState(store, ...)is a type error, so components must go through methods. You can opt out withsignalStore({ protectedState: false }, ...), but the default is the safer design. - Dependencies are injected as default parameters in the
withMethodsfactory, because it runs in an injection context. - Members prefixed with
_are private: a state slice, computed or method named_cacheis usable inside the store but not visible to consumers.
withComputed also accepts plain functions and wraps them in computed() for you, so remaining: () => tasks().filter(t => !t.done).length is equivalent.
withProps and withHooks #
withProps adds anything that isn't state, a derived signal or a method. A common use is grouping dependencies so later features can share them, or exposing an observable for RxJS-based consumers:
import { toObservable } from '@angular/core/rxjs-interop';
withProps(({ loading }) => ({
tasksService: inject(TasksService),
loading$: toObservable(loading),
})),
withHooks runs code when the store is created and destroyed. onInit runs in an injection context, so you can use takeUntilDestroyed() there:
withHooks({
onInit(store) {
store.load();
},
onDestroy(store) {
console.log('tasks store destroyed with', store.tasks().length, 'tasks');
},
}),
If the two hooks need to share variables or injected services, use the factory form, withHooks(store => { const logger = inject(Logger); return { onInit() {...}, onDestroy() {...} }; }).
Side effects: rxMethod and signalMethod #
Promise-based methods are fine for one-off loads. For inputs that change over time, such as a search query, rxMethod from @ngrx/signals/rxjs-interop gives you RxJS operators for debouncing and cancellation:
import { debounceTime, distinctUntilChanged, pipe, switchMap, tap } from 'rxjs';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
withMethods((store, tasksService = inject(TasksService)) => ({
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, { loading: true })),
switchMap(query =>
tasksService.search(query).pipe(
tapResponse({
next: tasks => patchState(store, { tasks }),
error: console.error,
finalize: () => patchState(store, { loading: false }),
}),
),
),
),
),
})),
A reactive method accepts a static value, a signal, a computation function or an observable. Pass a signal and the pipeline re-runs whenever it changes:
constructor() {
this.store.search(this.store.filter.query); // re-searches on every query change
}
signalMethod, imported from @ngrx/signals, is the same idea without RxJS: it accepts a value or a signal and runs a callback with each new value. It's smaller, but it has no operators like switchMap, so it doesn't cancel in-flight work. Use it for synchronous reactions and rxMethod for async work where races matter.
Both clean up automatically with the injection context they were called in. If you call a root-provided store's reactive method with a signal from inside a component, outside the constructor, pass the component's injector ({ injector }) so the subscription ends when the component is destroyed.
Entity collections #
Most stores manage a list of records keyed by id. @ngrx/signals/entities provides that shape and a set of updaters:
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import {
addEntity, removeEntity, setAllEntities, updateEntity, withEntities,
} from '@ngrx/signals/entities';
export const TasksStore = signalStore(
withEntities<Task>(),
withMethods(store => ({
loaded(tasks: Task[]) { patchState(store, setAllEntities(tasks)); },
add(task: Task) { patchState(store, addEntity(task)); },
toggle(id: string) {
patchState(store, updateEntity({ id, changes: t => ({ done: !t.done }) }));
},
remove(id: string) { patchState(store, removeEntity(id)); },
})),
);
withEntities<Task>() adds ids and entityMap as state and entities as a computed array. Lookups by id are a map read, and updating one entity leaves every other entity's reference untouched, which keeps @for rendering cheap (lesson 8.3).
| Updater | Does |
|---|---|
addEntity / addEntities |
Add; existing ids are left unchanged |
setEntity / setEntities |
Add or replace |
setAllEntities |
Replace the whole collection |
upsertEntity / upsertEntities |
Add, or merge into the existing entity |
updateEntity / updateEntities / updateAllEntities |
Partial update by id, ids or predicate |
removeEntity / removeEntities / removeAllEntities |
Remove by id, ids or predicate |
If your id property isn't called id, pass a selectId function to the add, set and update updaters, or define it once with entityConfig. To hold several collections in one store, name them: withEntities({ entity: type<Task>(), collection: 'task' }) produces taskIds, taskEntityMap and taskEntities.
Custom features #
The real payoff comes when you package a pattern once. signalStoreFeature combines features into a reusable one:
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
export function withRequestStatus() {
return signalStoreFeature(
withState<{ requestStatus: RequestStatus }>({ requestStatus: 'idle' }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
error: computed(() => {
const s = requestStatus();
return typeof s === 'object' ? s.error : null;
}),
})),
);
}
// standalone updaters, usable alongside others in one patchState call
export const setPending = () => ({ requestStatus: 'pending' as const });
export const setFulfilled = () => ({ requestStatus: 'fulfilled' as const });
Every store that needs loading state now adds one line:
export const TasksStore = signalStore(
withEntities<Task>(),
withRequestStatus(),
withMethods((store, api = inject(TasksService)) => ({
async load() {
patchState(store, setPending());
patchState(store, setAllEntities(await api.getAll()), setFulfilled());
},
})),
);
Root or component level #
A Signal Store is not registered anywhere by default. You choose:
| Provided | How | Lifetime | Use for |
|---|---|---|---|
| Root | signalStore({ providedIn: 'root' }, ...) |
Whole app | Global state: session, cart, settings |
| Route | providers: [TasksStore] on a route |
While the route's injector exists | State shared by a feature's routes |
| Component | providers: [TasksStore] in @Component |
That component instance | Screen state; each instance gets its own store |
Component-level stores are underrated. A complex editor screen gets its own store, withHooks loads its data, and everything is destroyed when the user navigates away.
A practical rule for sizing stores: one store per feature concept, not per component and not per app. If two stores constantly need each other's state, they are probably one store. If a store has grown sections that never interact, such as a product catalogue and a user's saved filters, split it, and let the smaller store inject the bigger one through withProps when it needs to read from it.
Testing a store #
Stores need an injection context, so test them through TestBed, not new. Mock dependencies with providers, and use unprotected from @ngrx/signals/testing to set up state directly:
import { TestBed } from '@angular/core/testing';
import { patchState } from '@ngrx/signals';
import { unprotected } from '@ngrx/signals/testing';
// tests the first TasksStore (withState + withComputed) from this lesson
describe('TasksStore', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TasksStore, { provide: TasksService, useValue: { getAll: async () => [] } }],
});
});
it('counts remaining tasks', () => {
const store = TestBed.inject(TasksStore);
patchState(unprotected(store), {
tasks: [{ id: '1', title: 'A', done: false }, { id: '2', title: 'B', done: true }],
});
expect(store.remaining()).toBe(1);
});
});
When a method is driven by a signal, flush the effect with TestBed.tick() before asserting (lesson 11.2 covers testing signals and effects).
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
NullInjectorError for the store |
Store isn't providedIn: 'root' and isn't in any providers array |
Add { providedIn: 'root' } or provide it on a route or component |
Type error calling patchState(store, ...) in a component |
State is protected by default | Add a store method; don't disable protection |
| A feature can't see a method or computed | Features only see what earlier features added | Reorder: dependencies must come first |
| Two components share state unexpectedly, or don't share it | Store provided at a different level than intended | Root for shared, component providers for per-instance |
rxMethod keeps running after the component is destroyed |
Called on a root store with a signal outside the component's injection context | Call it in the constructor, or pass { injector } |
| Search results arrive out of order | signalMethod used for async requests |
Use rxMethod with switchMap |
What's next #
Lesson 14.3 covers the classic NgRx Store: actions, reducers, createFeature, selectors, functional effects and Redux DevTools, plus how to move an existing NgRx Store app towards Signal Store one feature at a time. If you like Signal Store but want an event-driven flow, look at its Events plugin (@ngrx/signals/events), which adds events, reducers and event handlers on top of the same stores.
Try it yourself #
Take one service in your app that manages a list loaded from an API. Rewrite it as a Signal Store with withEntities, a custom withRequestStatus() feature and a load method, provide it at the component level, and write one TestBed test for a computed value.
search_documentationA root store is a single shared instance, so both panels read and write the same signals. Split it in two. Keep the catalogue data (the product entities and their loading status) in a root store, since both panels should share one copy. Move the per-panel state (filter, sort, selected ids) into a new ComparisonPanelStore created with signalStore(...) but without providedIn, and add it to providers: [ComparisonPanelStore] in the ComparisonPanel component. Each panel instance now gets its own store, destroyed with the panel. Inside it, inject the root store in withProps and derive the visible products with withComputed from the shared entities and the panel’s own filter.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


