NgRx Signal Store Deep Dive: State, Methods, Entities and Testing [2026]

Link copied
NgRx Signal Store Deep Dive: State, Methods, Entities and Testing [2026]

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 because filter is an object, also store.filter.query() and store.filter.showDone(). Nested signals are created lazily.
  • patchState takes partial state or an updater function, and you can pass several in one call: patchState(store, a, b). Like signal.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 with signalStore({ protectedState: false }, ...), but the default is the safer design.
  • Dependencies are injected as default parameters in the withMethods factory, because it runs in an injection context.
  • Members prefixed with _ are private: a state slice, computed or method named _cache is 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.

YouMy ProductsStore is providedIn root. When I open two product comparison panels side by side, changing the filter in one changes the other. How do I give each panel its own state?
Claude · used 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

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *