AngularAtlased. 22.2

Ch. 14Advanced reactivity63 / 75

NgRx SignalStore

Large-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.

architecture ~0 min read

NgRx SignalStore (the @ngrx/signals package) is a declarative way to build stores on top of signals. You compose features (withState, withComputed, withMethods, withHooks, withEntities…), and the result is an injectable class. Its state is exposed as signals and can be changed only through the methods you define.

It is the next step after the hand-made store on the State management page: the same ideas, but with a standard structure, ready-made entities, RxJS integration for asynchronous flows and reusable pieces across stores. The version used here is @ngrx/signals 22, compatible with Angular 22.

Why it matters
As the application grows, hand-written stores end up each looking different: some expose writable signals, others do not; some keep loading/error, others do not. SignalStore gives you one shape for all of them. And the repetitive parts (request state, pagination, entity collections) you write only once, as features.

Anatomy of a store

A signalStore(...) is a list of features applied in order. Each feature sees what the ones before it added. withState defines the state: each property becomes a signal (store.todos()), and nested objects become deep signals. withComputed adds derived state, withMethods adds the operations. Only methods may call patchState. The state is protected by default, so you cannot modify it directly from outside the store.

import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';

type Filter = 'all' | 'open' | 'done';
interface Todo { id: number; title: string; done: boolean }

export const TodoStore = signalStore(
  { providedIn: 'root' }, // without the option: put it in `providers` where you need it
  withState({ todos: [] as Todo[], filter: 'all' as Filter }),
  withComputed(({ todos, filter }) => ({
    visible: computed(() =>
      filter() === 'all' ? todos() : todos().filter((t) => t.done === (filter() === 'done')),
    ),
    openCount: computed(() => todos().filter((t) => !t.done).length),
  })),
  withMethods((store) => ({
    add(title: string): void {
      patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
    },
    toggle(id: number): void {
      patchState(store, (s) => ({ todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
    },
    setFilter(filter: Filter): void {
      patchState(store, { filter });
    },
  })),
);

patchState receives either a partial object ({ filter }) or a function (state) => Partial<State>. It also accepts as many updaters as you like, applied together. The state is immutable: you always return new objects and arrays, otherwise the signals do not notice the change (the same rule as for signal()).

Where the store lives

Like any service, the lifetime of a store is given by the injector where you provide it. With { providedIn: 'root' } you get a single global instance, good for the user session or a shopping cart. Without the option, you put it in the providers of a component or a route and get a new instance, destroyed together with it. This is the right choice for the state of a page.

// 1. Global: a single instance for the whole application
export const SessionStore = signalStore({ providedIn: 'root' }, withState({ user: null as User | null }));

// 2. Per page / per component: a new instance, destroyed together with the component
export const ProductsStore = signalStore(withEntities<Product>() /* ... */);

@Component({
  providers: [ProductsStore], // each <app-catalog> has its own store
  template: `...`,
})
export class Catalog {
  readonly store = inject(ProductsStore);
}

// 3. Per route (feature): lives as long as the route does
{ path: 'catalog', providers: [ProductsStore], loadComponent: () => import('./catalog') }
LiveLocal store vs root store
Instance 5
local: 0
root: 0
Instance 6
local: 0
root: 0
“local” dies with the component; “root” survives.
Press “local +1” in each card: each has its own instance. “root +1” increments the same counter in both. Destroy the cards and create them again: the local one starts from zero, the root one keeps its value.

Entities

withEntities<Product>() adds a normalised collection: in the state you have ids and entityMap, and as a signal you have entities(), a derived array. The updaters from @ngrx/signals/entities (setAllEntities, addEntity, updateEntity, upsertEntity, removeEntity…) are passed directly to patchState. For several collections in the same store there are named collections (withEntities({ entity: type<Product>(), collection: 'product' }) → productEntities()).

Asynchronous flows with rxMethod

rxMethod (from @ngrx/signals/rxjs-interop) turns an RxJS pipe into a method. You can call it with a value, with an Observable or with a signal. With a signal, the method runs on every change of it. The call store.search(store.query) in onInit links the search to the query only once. The rest is ordinary RxJS: debounceTime, switchMap to cancel the outdated request, catchError inside the switchMap, so that an error does not stop the stream (see Advanced RxJS). The subscription closes by itself when the store is destroyed.

import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, EMPTY, pipe, switchMap, tap } from 'rxjs';

export const ProductsStore = signalStore(
  withEntities<Product>(),                 // state: ids + entityMap; signal: entities()
  withState({ query: '' }),
  withRequestStatus(),                     // custom feature (below)
  withMethods((store, http = inject(HttpClient)) => ({
    setQuery: (query: string) => patchState(store, { query }),
    buy: (id: number) =>
      patchState(store, updateEntity({ id, changes: (p) => ({ stock: p.stock - 1 }) })),

    search: rxMethod<string>(
      pipe(
        debounceTime(300),
        distinctUntilChanged(),
        tap(() => patchState(store, setPending())),
        switchMap((q) =>
          http.get<Page<Product>>(`/api/products?q=${encodeURIComponent(q)}`).pipe(
            tap((page) => patchState(store, setAllEntities(page.items), setFulfilled())),
            catchError(() => {
              patchState(store, setError('The server is not responding.'));
              return EMPTY; // the outer stream stays alive
            }),
          ),
        ),
      ),
    ),
  })),
  withHooks({
    onInit(store) {
      store.search(store.query); // rxMethod receives a SIGNAL: it runs on every change
    },
  }),
);
tapResponse
In the NgRx documentation you will often see tapResponse instead of tap + catchError. It does the same thing more compactly, but comes from the separate @ngrx/operators package. The demo here uses only RxJS.

Reusable features

signalStoreFeature(...) groups several features into a function that you add to any store, for example withRequestStatus(): the request state plus isPending and error. The updaters are simple functions that return a piece of state, so you combine them with others in the same patchState. This removes the copy-pasted loading/error code from every store.

import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';

export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };

// A reusable piece of store: state + computeds, composed into any signalStore
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;
      }),
    })),
  );
}

// Updaters: simple functions that return a piece of state → patchState(store, setPending())
export const setPending = () => ({ requestStatus: 'pending' as RequestStatus });
export const setFulfilled = () => ({ requestStatus: 'fulfilled' as RequestStatus });
export const setError = (error: string) => ({ requestStatus: { error } as RequestStatus });
LiveProducts store: entities + rxMethod + custom feature
  • No products.
getState(store) — live
{
  "query": "",
  "inStockOnly": false,
  "cart": 0,
  "requestStatus": "idle",
  "ids": []
}
In cart: 0 · stock value: 0 RON
Type in the search: the request leaves only after a 300 ms pause. “Buy” changes a single entity with updateEntity. On the right you can see getState(store) updated live.

The rest of the toolbox

signalState is the “no store” option: an object of signals for the local state of a component, still with patchState. withProps adds properties that are not state (services, resources, observables). withLinkedState does for a store what linkedSignal does for a component. getState gives you a snapshot of the state, and watchState notifies you synchronously on every change. For tests, unprotected(store) from @ngrx/signals/testing lets you put the store in a given state. The package also has an events plugin (@ngrx/signals/events), for the Redux-style approach with events and reducers.

import { patchState, signalState } from '@ngrx/signals';

// For the local state of ONE component: no store, just an object of signals
@Component({ /* ... */ })
export class Filters {
  readonly state = signalState({ query: '', page: 1, sort: 'name' as 'name' | 'price' });

  next(): void {
    patchState(this.state, (s) => ({ page: s.page + 1 }));
  }
  // in the template: state.query(), state.page()
}

When YES, when NO

Use SignalStore
  • Feature state used by several components, with asynchronous operations (search, saving, pagination).
  • Entity collections that are updated often (editable lists, carts, tables).
  • Several teams that want the same shape for all stores, and reusable features.
  • You have classic NgRx Store and want something lighter for new features. They can coexist.
Something simpler is enough
  • The state of a single component: signal() or signalState.
  • Data read from the server and only displayed: httpResource (see resource).
  • A small service with two signals and three methods: a hand-made store is clearer for everyone.
  • Applications where nobody on the team knows NgRx and the deadline is tomorrow. The dependency has a learning cost.
Hand-made storeSignalStoreNgRx Store (Redux)
Boilerplateminimalsmalllarge (actions, reducers, effects, selectors)
Standard structurewhatever the team decidesyesyes, very strict
Entitiesyou write them yourselfwithEntities@ngrx/entity
Asyncresource / RxJSrxMethod, resourceeffects
Good forsmall featuresmost applicationscomplex flows, event auditing

Common pitfalls

  • Mutating the state (s.todos.push(x) in an updater). The signal receives the same reference and nothing updates. Always return new arrays and objects.
  • providedIn: 'root' for the state of a page. The state “sticks” from one visit to the next and gets mixed between component instances. Put the store in the page’s providers.
  • catchError placed after switchMap in an rxMethod. The first error closes the stream and the search stops working. Put it in the inner stream.
  • Business logic in components, with the store used only as a “bag of data”. The store’s methods are the place for it.
  • One huge store for the whole application. Make small stores, per feature, and compose them with features. That is easier to test and to load lazily.
  • effect() in components to synchronise stores with each other. Prefer withComputed or withLinkedState (see effect()).

Cheat sheet

signalStore(config?, ...features)        config: { providedIn: 'root' } or nothing (→ providers: [Store])
withState({ ... })                       state → signals: store.x()
withComputed(store => ({ y: computed(...) }))
withMethods((store, dep = inject(X)) => ({ ... }))   the only place where you modify the state
withHooks({ onInit(store) {}, onDestroy(store) {} })
withProps / withLinkedState              non-state properties / linked state (like linkedSignal)
signalStoreFeature(...features)          reusable pieces: withRequestStatus(), withPagination()
patchState(store, partial | updater, ...) several updaters in a single call
getState(store) / watchState(store, fn)   snapshot / synchronous observer of the state
rxMethod<T>(pipe(...))                   from @ngrx/signals/rxjs-interop — accepts a value, a signal or an Observable
withEntities<T>() + setAllEntities, addEntity, updateEntity, removeEntity, upsertEntity  (@ngrx/signals/entities)
signalState({ ... })                     local state in a component, without a store
unprotected(store)                       from @ngrx/signals/testing — set the state directly in tests

Ch. 14 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Signals under the hood
  2. Advanced RxJS: higher-order operators
  3. NgRx SignalStore
  4. State machines with signals
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation