Mert Özen Further With Every Line
Frontend State Management in Angular — #05

Getting Started with NgRx Signal Store: signalStore, withState, withComputed, withMethods and patchState

Mert Özen Nov 14, 2026 14 min 12 views
Getting Started with NgRx Signal Store: signalStore, withState, withComputed, withMethods and patchState

NgRx Signal Store provides the parts of a hand-written signal store as ready-made features: withState for state signals, withComputed for derived values, withMethods and patchState for immutable updates, withProps and withHooks for dependencies and lifecycle. We convert Bookstore's cart and checkout without changing component code and show with real output protected state, private members, NgRx 22's union DeepSignal, the Map pitfall and a cart lost when the persisted shape changes.

Version note: The examples in this post were run with Angular 22.2, NgRx 22.0.1 (@ngrx/signals), TypeScript 6.0 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0); browser behaviour was observed with Playwright 1.65 in headless Chromium against the mock Bookstore API in the snapshot.

Predict First

In bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, the cart is now an NgRx Signal Store. A teammate who wants to add an "Empty cart" button to the cart page writes this method in the component; patchState is Signal Store's state update function:

  emptyCart(): void {
    patchState(this.cart, { items: [] });
    localStorage.removeItem(this.cart._storageKey);
    console.log(this.cart.coupon.code());
  }

Each of the three lines tries something that had a counterpart in the hand-written store of the second post: changing state from outside, reading an internal detail of the store, and reaching a field of nested state. Does ng build compile this method? If not, which lines fail and why? Write down your prediction; the compiler's answer is in the "Protected State" section.

In Block A we used the kinds of state and Angular's own tools. With this post we move to NgRx: we turn the CartStore we wrote by hand with @Service() in the second post, and the checkout store, into NgRx Signal Stores. The focus is which part of the hand-written version signalStore, withState, withComputed, withMethods, patchState, withProps and withHooks replace; signalState for small state; and when a Signal Store is unnecessary. Entities, rxMethod and custom features come in the next post.

From a Hand-Written Store to signalStore

Installing the package is a single command (@ngrx/signals has been in the project since the third post for the resource extensions):

npm install @ngrx/signals

The Signal Store version of the cart, with coupon support:

import { HttpClient } from '@angular/common/http';
import { computed, effect, inject } from '@angular/core';
import {
  getState,
  patchState,
  signalStore,
  withComputed,
  withHooks,
  withMethods,
  withProps,
  withState,
} from '@ngrx/signals';
import { firstValueFrom } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, CartItem, Coupon } from '../models';

type CartState = {
  items: CartItem[];
  coupon: Coupon | null;
};

const initialState: CartState = { items: [], coupon: null };

// Global client state: one instance for the whole app (root injector)
export const CartStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  // Dependencies and constants; the _ prefix keeps them private to the store
  withProps(() => ({
    _http: inject(HttpClient),
    _storageKey: 'bookstore.cart',
  })),
  withComputed(({ items }) => ({
    count: computed(() => items().reduce((sum, item) => sum + item.quantity, 0)),
    totalPrice: computed(() => items().reduce((sum, item) => sum + item.price * item.quantity, 0)),
  })),
  withComputed(({ totalPrice, coupon }) => ({
    discount: computed(() => {
      const c = coupon();
      return c ? Math.round(totalPrice() * c.percent) / 100 : 0;
    }),
  })),
  withComputed(({ totalPrice, discount }) => ({
    payable: computed(() => totalPrice() - discount()),
  })),
  withMethods((store) => ({
    add(book: Book): void {
      patchState(store, ({ items }) => ({
        items: items.some((item) => item.isbn === book.isbn)
          ? items.map((item) =>
              item.isbn === book.isbn ? { ...item, quantity: item.quantity + 1 } : item,
            )
          : [...items, { isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }],
      }));
    },
    setQuantity(isbn: string, quantity: number): void {
      patchState(store, ({ items }) => ({
        items:
          quantity < 1
            ? items.filter((item) => item.isbn !== isbn)
            : items.map((item) => (item.isbn === isbn ? { ...item, quantity } : item)),
      }));
    },
    remove(isbn: string): void {
      patchState(store, ({ items }) => ({ items: items.filter((item) => item.isbn !== isbn) }));
    },
    clear(): void {
      patchState(store, initialState);
    },
    async applyCoupon(code: string): Promise<boolean> {
      try {
        const url = `${apiUrl}/coupons/${encodeURIComponent(code)}`;
        const coupon = await firstValueFrom(store._http.get<Coupon>(url));
        patchState(store, { coupon });
        return true;
      } catch {
        patchState(store, { coupon: null });
        return false;
      }
    },
  })),
  withHooks({
    onInit(store) {
      // Hydrate once from localStorage, then save every state change (outward sync only)
      patchState(store, readSavedCart(store._storageKey));
      effect(() => localStorage.setItem(store._storageKey, JSON.stringify(getState(store))));
    },
  }),
);

function readSavedCart(key: string): Partial<CartState> {
  try {
    const saved = JSON.parse(localStorage.getItem(key) ?? '{}') as Partial<CartState>;
    return Array.isArray(saved.items) ? { items: saved.items, coupon: saved.coupon ?? null } : {};
  } catch {
    return {}; // storage blocked or corrupt JSON: start with an empty cart
  }
}

signalStore returns a class, and that class is injected like a service; so CartStore is still obtained with inject(CartStore). Its content is made of features applied in order; each feature sees everything the previous ones added. How it maps to the parts of the hand-written store:

Hand-written store (02)Signal Store (05)Note
@Service(){ providedIn: 'root' }A Signal Store is registered with no injector by default
#items = signal(...)withState(initialState)A signal is generated for each state field
items = this.#items.asReadonly()AutomaticState is exposed read-only (protectedState)
count = computed(...)withComputed(...)The same computed; a plain function is accepted too
add(), this.#items.update(...)withMethods(...), patchState(...)Updates are still immutable
readonly #http = inject(...)withProps(() => ({ _http: inject(...) }))The _ prefix makes it private
The effect in the constructorwithHooks({ onInit })Runs in an injection context

One note from the NgRx documentation matters: a Signal Store is not provided anywhere unless providedIn: 'root' is given, and it has to be added to a providers list. That is the same default as @Service({ autoProvided: false }) in the second post; that is why the checkout store is written without providedIn.

withState, withComputed, withMethods and patchState

withState takes the initial value of the state and generates a signal for each field: store.items(), store.coupon(). NgRx's state type must be an object literal (a record); fields can be arrays, primitives or nested objects. The signal generated for a nested object is a DeepSignal: it can be read itself, and it also has separate signals for its sub-fields (such as store.address.city()). These signals are created lazily, on first access.

NgRx 22 brought a breaking change here. The type of coupon is the union Coupon | null. Up to NgRx 21 such a field was a single Signal<Coupon | null>; in NgRx 22 a separate DeepSignal is generated for each object literal in the union, and the type becomes DeepSignal<Coupon> | Signal<null>. In practice store.coupon() works as before; store.coupon.code(), however, can only be written after narrowing, such as 'code' in store.coupon. We will see below what that means at compile time.

withComputed takes the signals the previous features added and returns new derived signals. discount is in a second withComputed because it needs totalPrice; for one computed to use another inside the same feature, the NgRx documentation suggests a helper variable or a separate feature. withMethods defines the methods that change state, and the only way to write is patchState. It has two forms: a partial object (patchState(store, { coupon })) or an updater that takes the current state and returns a partial state (patchState(store, ({ items }) => ({ items: [...] }))). In both, the update must be immutable; the second post's "don't return the same reference" rule applies here too, and one of the pitfalls below shows it.

applyCoupon is an async method: it asks the API about the coupon code and writes it to the state if it is valid. Using a Promise for async work is perfectly valid in a Signal Store; for those who want to build the same request flow with RxJS operators, rxMethod is in the next post.

withProps, withHooks and the Checkout Store

withProps adds everything that is not state to the store: injected dependencies, constants, Observables. Members starting with _ are private in NgRx: the store's own features see them, and they cannot be reached from outside. withHooks defines code that runs when the store is created (onInit) and destroyed (onDestroy); since onInit runs in an injection context, an effect can be created inside it. In the cart we use this for persistence: read from localStorage once, then write on every change. getState(store) tracks the whole state when it is read in a reactive context.

The checkout store was converted the same way:

import { computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import { CartStore } from '../cart/cart-store';

export type ShippingMethod = 'standard' | 'express';
let instances = 0;

// Feature state: no providedIn; the 'checkout' route lists it in its providers
export const CheckoutStore = signalStore(
  withState({ shipping: 'standard' as ShippingMethod }),
  withProps(() => ({ _cart: inject(CartStore), _instance: ++instances })),
  withComputed(({ shipping, _cart }) => ({
    shippingPrice: computed(() => (shipping() === 'express' ? 90 : _cart.payable() >= 500 ? 0 : 40)),
  })),
  withComputed(({ shippingPrice, _cart }) => ({
    payable: computed(() => _cart.payable() + shippingPrice()),
  })),
  withMethods((store) => ({
    chooseShipping(shipping: ShippingMethod): void {
      patchState(store, { shipping });
    },
  })),
  withHooks({
    onInit: ({ _instance }) => console.log(`[checkout] store #${_instance} created`),
    onDestroy: ({ _instance }) => console.log(`[checkout] store #${_instance} destroyed`),
  }),
);

The dependency (_cart) and the instance number are in withProps; the created/destroyed logs are in withHooks. The route definition did not change at all: providers: [CheckoutStore]. The shipping price calculation now uses the amount after the coupon discount (_cart.payable()).

The Component Code Did Not Change

The best part of the conversion is that almost nothing changes in the components that use the store. The header, the catalog, the cart and the checkout pages use inject(CartStore) and the same names, such as cart.count(), cart.add(book) and cart.setQuantity(...); that API was chosen on purpose. Only a coupon field was added to the cart page:

    <p>
      <input class="coupon-code" placeholder="Coupon" #code />
      <button class="apply" (click)="cart.applyCoupon(code.value)">Apply</button>
    </p>
    @if (cart.coupon(); as coupon) {
      <p class="discount">{{ coupon.code }}: -{{ cart.discount() }} TRY</p>
    }
    <p class="total">Total: {{ cart.payable() }} TRY</p>

The persistence scenario from the second post, with the same unchanged Playwright script:

after adding   : Cart (3)
after reload   : Cart (3)
cart page      : Total: 1220 TRY
localStorage   : {"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}

The format in localStorage changed: it is no longer an array but the state object returned by getState. The coupon scenario (an invalid code, a valid code, then a reload):

cart                   - | Total: 770 TRY
 2.3 s console: Failed to load resource: the server responded with a status of 404 (Not Found)
apply FREE50           - | Total: 770 TRY
apply BOOKWEEK         BOOKWEEK: -77 TRY | Total: 693 TRY
after reload           BOOKWEEK: -77 TRY | Total: 693 TRY
localStorage: {"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":1},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":{"code":"BOOKWEEK","percent":10}}

The invalid code got a 404 and nothing was written to the state; BOOKWEEK applied a 10% discount to 770 TRY, and since the coupon is part of the state, it is still there after the reload. Checkout's provider scope scenario gives the same result as in the second post; this time the logs come from withHooks:

 1.5 s console: [checkout] store #1 created
          step 1: shipping selected = standard
          step 2: Books 450 TRY + express shipping 90 TRY = 540 TRY
 2.3 s console: [checkout] store #1 destroyed
          left checkout for the catalog
 2.9 s console: [checkout] store #2 created
          back in checkout: shipping selected = standard

Protected State, Private Members and the Union DeepSignal

The compile result of the method from "Predict First":

✘ [ERROR] TS2345: Argument of type '{ items: Signal<CartItem[]>; coupon: DeepSignal<Coupon> | Signal<null>; count: Signal<number>; totalPrice: Signal<number>; ... 6 more ...; applyCoupon: (code: string) => Promise<...>; } & StateSource<...>' is not assignable to parameter of type 'WritableStateSource<{ items: CartItem[]; coupon: Coupon | null; }>'.
  The types of '[STATE_SOURCE].items' are incompatible between these types.
    Type 'Signal<CartItem[]>' is missing the following properties from type 'WritableSignal<CartItem[]>': set, update, asReadonly, [ɵWRITABLE_SIGNAL] [plugin angular-compiler]

    src/app/cart/cart-page.ts:40:15:
      40 │     patchState(this.cart, { items: [] });
         ╵                ~~~~~~~~~

✘ [ERROR] TS2339: Property '_storageKey' does not exist on type '{ items: Signal<CartItem[]>; coupon: DeepSignal<Coupon> | Signal<null>; count: Signal<number>; totalPrice: Signal<number>; ... 6 more ...; applyCoupon: (code: string) => Promise<...>; } & StateSource<...>'. [plugin angular-compiler]

    src/app/cart/cart-page.ts:41:38:
      41 │     localStorage.removeItem(this.cart._storageKey);
         ╵                                       ~~~~~~~~~~~

✘ [ERROR] TS2339: Property 'code' does not exist on type 'DeepSignal<Coupon> | Signal<null>'.
  Property 'code' does not exist on type 'Signal<null>'. [plugin angular-compiler]

    src/app/cart/cart-page.ts:42:33:
      42 │     console.log(this.cart.coupon.code());
         ╵                                  ~~~~

Three lines, three errors. The first is Signal Store's protectedState behaviour: according to the NgRx documentation, state is closed to changes from outside by default. Calling patchState from outside the store is a type error, because the state signals are Signal, not WritableSignal; the counterpart of asReadonly() in the second post, without writing extra code. signalStore({ protectedState: false }, ...) removes this protection; the documentation does not recommend it, and neither do we. The right fix is the store's clear().

The second is that members with the _ prefix are not exposed at all: _storageKey is not in the store's type. The third is NgRx 22's union change: since coupon can also be a Signal<null>, .code cannot be reached directly. In the template, reading the value and narrowing it with @if (cart.coupon(); as coupon) is the simplest way.

signalState: For Small State

Not every piece of state needs a store. The review form on the book details page had three signals (text, saving, saveError). NgRx's signalState gathers the same state in a single object without needing an injector, a provider or features:

  // Small local state: one signalState instead of three separate signals
  protected readonly form = signalState({ text: '', saving: false, error: null as string | null });

  constructor() {
    effect(() => console.log(`[book] ${this.book.status()} | [related] ${this.related.status()}`));
    effect(() => console.log(`[reviews] ${this.reviews.status()}: ${this.reviews.value().length} reviews`));
  }

  protected setText(text: string): void {
    patchState(this.form, { text });
  }

signalState returns a signal (form() is the whole state) and generates separate signals for each field (form.text()); updates again go through patchState. Since it is defined inside the component, its lifetime is the component's lifetime; that is exactly the first post's definition of "local UI state". Three separate signals were not wrong either; what signalState gains is updating fields that belong together with a single patchState call ({ saving: true, error: null }). After this change, the add-review scenario gave the same output as in the third post.

In Production

The shape of persisted state is a contract. The cart in the previous posts wrote an array to localStorage; the Signal Store version writes an object. When this post's code is opened in a browser that has a cart saved in the old shape:

header      : Cart (0)
localStorage: {"items":[],"coupon":null}

The two copies of Dune in the user's cart disappeared silently: readSavedCart did not recognise the old shape, fell back to an empty cart, and the effect deleted the old data by saving the empty state. On its own the code behaves correctly (it does not start with broken data), but in a real release the user experiences it as a lost cart. When you change the shape of persisted state, add a version field ({ version: 2, items, coupon }), write a small migration that reads the old shape and converts it to the new one, and protect it with a test.

A Signal Store provided at the root has the same lifetime as the root service in the second post: it does not reset itself when the user signs out. clear() returns the state to initialState, and the effect writes that to localStorage as well; the sign-out flow should call this method.

Watching Signal Store state with Redux DevTools is not in the official NgRx packages; for that, the NgRx documentation points to withDevtools in the community package @angular-architects/ngrx-toolkit (22.0.0 on npm). We did not use it in this series; it will be covered in the post on choosing a tool.

Common Pitfalls

Putting a Map or a class instance in state

The NgRx documentation wants state to be a record and updates to be immutable. Putting a Map in state and changing it in place passes the compiler, but the change is not detected. A small experiment run in the browser when the app starts:

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

export function runMapDemo(): void {
  // wrong: a Map mutated in place; the state slice keeps the same reference
  const withMap = signalState({ quantities: new Map<string, number>() });
  const mapCount = computed(() => [...withMap.quantities().values()].reduce((a, b) => a + b, 0));
  console.log('[demo] Map    count before:', mapCount());
  patchState(withMap, ({ quantities }) => {
    quantities.set('9780441172719', 2);
    return { quantities };
  });
  console.log('[demo] Map    size after:', withMap.quantities().size, '| count after:', mapCount());

  // right: a plain record, replaced with a new object
  const withRecord = signalState({ quantities: {} as Record<string, number> });
  const recordCount = computed(() => Object.values(withRecord.quantities()).reduce((a, b) => a + b, 0));
  console.log('[demo] Record count before:', recordCount());
  patchState(withRecord, ({ quantities }) => ({ quantities: { ...quantities, '9780441172719': 2 } }));
  console.log('[demo] Record count after:', recordCount());
}
 0.1 s console: [demo] Map    count before: 0
 0.1 s console: [demo] Map    size after: 1 | count after: 0
 0.1 s console: [demo] Record count before: 0
 0.1 s console: [demo] Record count after: 2

The Map has one entry, yet the computed depending on it is still 0: the same Map reference was returned to patchState, and as far as the signal is concerned nothing changed. When the same data is kept in a plain record and updated with a new object, the computed gives the right value. The same rule applies to class instances (such as Date or your own domain classes): produce a new one instead of changing it in place, and keep plain data in state where you can.

Providing every store at the root

Writing { providedIn: 'root' } is easy, but the scope rule from the first post applies here too. Had the checkout store been at the root, not even withAutoCleanupInjectors() could have fixed the "the express choice came back" output from the second post. Leave the root to state that really must have a single copy for the whole app (the cart, the session); provide feature state in the route's providers and local state in the component's.

Adding a Signal Store for a single component's few signals

Writing signalStore, withState and withMethods to hold whether a dialog is open and which tab is selected turns the job of two signals into a store file, a provider and a test setup. At this scale a plain signal or a signalState is enough; a Signal Store pays for itself when several components share the same state, when methods carry business rules, or when you want to test the store.

Copying state by hand inside the store

Writing patchState(store, { count: ... }) with withHooks + effect instead of withComputed is the Signal Store version of the second post's "copying a derived value with an effect" pitfall. Everything that can be derived goes in withComputed.

A Small Experiment

★☆☆ Add a "Remove coupon" button next to the coupon box on the cart page. Which method do you need to add to the store, and why can't you call patchState from the component?

★★☆ Write a version field and a migration for the persisted cart: readSavedCart should recognise the old array shape and convert it to { items, coupon: null }. Run the Playwright scenario from "In Production" again and verify that the two copies of Dune are not lost.

★★★ Move the catalog's cover dialog and the view preference (list or grid) into a signalState; then write the same state as a small Signal Store provided at component level (providers: [CatalogViewStore]). Compare the two versions in terms of lines of code, testability and closing the dialog when the category changes (the job linkedSignal does).

In the next post we move to Signal Store's advanced parts: moving cart lines into an entity collection with withEntities, async work with rxMethod and signalMethod, withLinkedState, and a withRequestStatus() custom feature that can be added to any store.