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

Signal-Based Service State: Writing a Store with Read-Only Signals, computed and Angular 22's @Service() Decorator

Mert Özen Nov 14, 2026 14 min 12 views
Signal-Based Service State: Writing a Store with Read-Only Signals, computed and Angular 22's @Service() Decorator

A library-free Angular store is a private writable signal, a read-only view exposed with asReadonly(), derived values with computed and methods that update state immutably; its lifetime is set by the injector that provides it. We write Bookstore's cart with Angular 22's @Service() decorator and show with real output what an update returning the same array, a route-level store without withAutoCleanupInjectors() and an effect writing its own signal actually do.

Version note: The examples in this post were run with Angular 22.2, 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 lives in a service, in a private signal; the header reads the cart count from a computed called count. A teammate wrote the add method like this to make it "shorter":

  add(book: Book): void {
    // wrong: mutates the array and returns the same reference
    this.#items.update((items) => {
      items.push({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 });
      return items;
    });
    console.log(`[cart] array length ${this.#items().length}, count() ${this.count()}`);
  }

In the catalog, the user adds Dune and then Emma to the cart. Two lines will appear in the console and the header will show something. What is the array's length, what does count() return, and what does the header say? Settle your prediction before reading on; the answer is in the "Immutable Updates" section.

In the previous post we separated the kinds of state and where each should live. In this post we turn shared client state, the cart, into a real store without a library such as NgRx, using only Angular's own tools: a private writable signal and a read-only view, derived values with computed, immutable updates, Angular 22's @Service() decorator, state lifetime through provider scope, linkedSignal, and the right place for effect. The signal APIs themselves are in the Angular series ("What's New in Angular 16: Signals, Required Inputs and takeUntilDestroyed", "Angular 19: Standalone by Default, linkedSignal and Data Loading with resource()"); the idea of unidirectional data flow is in the UI Architecture Patterns series post "Unidirectional Data Flow in Angular: Signals, input/output and State in Services".

A Hand-Written Store: Private Signal, Read-Only View

This is the cart at the end of this post:

import { computed, effect, Service, signal } from '@angular/core';
import type { Book, CartItem } from '../models';

const storageKey = 'bookstore.cart';

// Global client state: one instance for the whole app (root injector)
@Service()
export class CartStore {
  readonly #items = signal<CartItem[]>(readSavedCart());

  // Read-only view of the state; every change goes through a method below
  readonly items = this.#items.asReadonly();
  readonly count = computed(() => this.#items().reduce((sum, item) => sum + item.quantity, 0));
  readonly totalPrice = computed(() =>
    this.#items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  constructor() {
    // Synchronise outwards only: write the cart to localStorage whenever it changes
    effect(() => localStorage.setItem(storageKey, JSON.stringify(this.#items())));
  }

  add(book: Book): void {
    this.#items.update((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 {
    if (quantity < 1) {
      this.remove(isbn);
      return;
    }
    this.#items.update((items) =>
      items.map((item) => (item.isbn === isbn ? { ...item, quantity } : item)),
    );
  }

  remove(isbn: string): void {
    this.#items.update((items) => items.filter((item) => item.isbn !== isbn));
  }

  clear(): void {
    this.#items.set([]);
  }
}

function readSavedCart(): CartItem[] {
  try {
    const saved = localStorage.getItem(storageKey);
    return saved ? (JSON.parse(saved) as CartItem[]) : [];
  } catch {
    return []; // storage blocked or corrupt JSON: start with an empty cart
  }
}

The structure comes down to four rules. First, the state has a single owner: #items. Unlike TypeScript's private, a JavaScript private field written with # is inaccessible at runtime too; neither a component nor a test can reach this signal directly. Second, the exposed items is a Signal<CartItem[]> obtained with asReadonly(); it can be read but has no set or update method. Third, every derived value (count, totalPrice) is a computed; none is stored in a separate field. Fourth, every change goes through a named method: add, setQuantity, remove, clear.

The last rule looks small, but it is what makes a store a store. The rule "a cart line is removed when its quantity drops to zero" lives inside setQuantity; every place that changes a quantity gets the rule for free. Had you exposed the signal, every component would write the same rule itself, and one of them would forget. We will see what the read-only view buys at compile time, with a real error message, in "Common Pitfalls".

The header, the cart page and checkout get this instance with inject(CartStore) and read the signals in their templates. There is no subscription anywhere in the components' code: in Angular 22 components are OnPush by default and the app is zoneless; when a signal read in a template changes, Angular checks only that view again. The details of these defaults are in the Angular series post "Angular 22: OnPush by Default, Stable Signal Forms and a File-by-File Comparison with Angular 12".

@Service() and inject()

Angular 22 brought a short way to provide a service at the root: @Service(). The Angular documentation describes it as shorthand for @Injectable({ providedIn: 'root' }), and ng generate service now generates it directly. Its limits are clear too: dependencies can only be obtained with the inject() function, constructor parameter injection is not supported; instead of advanced provider options such as useClass and useValue there is a single factory option; and there are no non-root scopes such as 'platform'. If you need any of those, @Injectable is still valid.

The option that matters when writing stores is autoProvided: false. By default, @Service() registers the class with the root injector itself; autoProvided: false turns that registration off, and the service then exists only where it is added to a providers list. The checkout store in the next section is exactly that.

inject() also makes a design decision easier: the store's dependencies sit as fields right next to where they are used (readonly #cart = inject(CartStore)). The constructor's only job is setup work such as registering an effect or writing a log line, when needed.

Immutable Updates: Why Doesn't the Same Array Update the Screen?

The add from "Predict First", in a real browser:

 0.9 s console: [cart] array length 1, count() 0
 1.2 s console: [cart] array length 2, count() 0
header      : Cart (0)
localStorage: []

The array grew to two elements, yet count() is still 0, the header says "Cart (0)" and nothing was written to localStorage. The reason is how a signal detects a change. update writes the value returned by the callback with set, and set compares the new value with the old one using Object.is. Because the callback returned the same array reference, nothing changed as far as the signal is concerned: its version did not increase, the computeds depending on it returned their cached result, the effect did not run and none of the OnPush components was checked again. Worse, the data really did change; on the next real update everything will appear at once and the bug will look random.

In the correct version, add always returns a new array: either a new line with its quantity increased via map, or a new line appended with spread. Unchanged lines keep the same reference; that also lets @for with track item.isbn leave the DOM elements of unchanged lines as they are. The rule in short: don't touch the objects and arrays inside a signal, produce new ones.

effect Only for Synchronising Outwards

In the previous post's run, the cart was lost on reload. The single effect in the store's constructor fixes that: it writes the cart to localStorage whenever it changes, and when the app starts, readSavedCart() reads the initial value from there. We added Dune twice and Emma once and reloaded the page:

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

This is the use of effect that the Angular documentation also recommends: keeping signal state in sync with an API that is not signal-based (localStorage, a chart library, analytics). The effect does not produce state; it writes outwards. An effect created at the root runs asynchronously, before the app's change detection; so three add calls in a row within the same task produce not three writes to localStorage but one write of the final state. The same asynchrony means that when writing tests you need an extra step to trigger the effect; that topic is in the series' testing post.

The try/catch in readSavedCart is not decoration. Browser storage may be blocked, or the saved JSON may be in an older version's shape; in both cases the cart should start empty, not stop the app from starting. Versioning persisted state and consistency across tabs are outside this post's scope; for now we fall back to an empty cart on error.

Provider Scope = State Lifetime

The cart is at the root; living as long as the app is right for it. Checkout, though, is a two-step flow (choosing shipping, the order summary), and the state the two steps share should be forgotten when the user leaves checkout. Putting that state at the root would be the previous post's "state higher than it should be" mistake; putting it in a component would lose it between the steps. The right place is the checkout route's own injector:

import { computed, DestroyRef, inject, Service, signal } from '@angular/core';
import { CartStore } from '../cart/cart-store';

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

// Feature state: not provided in root; the 'checkout' route lists it in its providers
@Service({ autoProvided: false })
export class CheckoutStore {
  readonly #cart = inject(CartStore);
  readonly #instance = ++instances;
  readonly #shipping = signal<ShippingMethod>('standard');

  readonly shipping = this.#shipping.asReadonly();
  readonly shippingPrice = computed(() =>
    this.#shipping() === 'express' ? 90 : this.#cart.totalPrice() >= 500 ? 0 : 40,
  );
  readonly payable = computed(() => this.#cart.totalPrice() + this.shippingPrice());

  constructor() {
    console.log(`[checkout] store #${this.#instance} created`);
    inject(DestroyRef).onDestroy(() => console.log(`[checkout] store #${this.#instance} destroyed`));
  }

  chooseShipping(method: ShippingMethod): void {
    this.#shipping.set(method);
  }
}
import { Routes } from '@angular/router';
import { CartPage } from './cart/cart-page';
import { CatalogPage } from './catalog/catalog-page';
import { CheckoutReview } from './checkout/checkout-review';
import { CheckoutShipping } from './checkout/checkout-shipping';
import { CheckoutStore } from './checkout/checkout-store';

export const routes: Routes = [
  { path: '', component: CatalogPage },
  { path: 'cart', component: CartPage },
  {
    path: 'checkout',
    // one CheckoutStore for all checkout steps; gone when the user leaves checkout
    providers: [CheckoutStore],
    children: [
      { path: '', pathMatch: 'full', redirectTo: 'shipping' },
      { path: 'shipping', component: CheckoutShipping },
      { path: 'review', component: CheckoutReview },
    ],
  },
];

For a route with a providers list, the router creates a separate EnvironmentInjector; the shipping and review child routes see the same CheckoutStore instance. So what happens to that injector when the user leaves checkout? The scenario: Dune is in the cart, the user enters checkout, picks express shipping, moves to the summary page, then goes back to the catalog and enters checkout again. First with the router's default settings:

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

The store was created once and never destroyed; on the second visit the user finds the express choice left over from the previous visit. By default, the router keeps route injectors for the lifetime of the app. The withAutoCleanupInjectors() feature, stable in Angular 22.2, changes that: after a navigation, the router destroys the injectors of routes that are no longer active (the default RouteReuseStrategy allows this). The app's router configuration:

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding(), withAutoCleanupInjectors()),
    provideHttpClient(),
  ],
};

The same scenario:

 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

When the user moved to the catalog, #1 was destroyed (the DestroyRef.onDestroy log), and on the way back #2 started with clean state. This is exactly what it means for provider scope to define state lifetime: you wrote no code to reset the state; the injector's lifetime became the state's lifetime. So if you use route-level stores, make this setting an explicit decision; without it, "feature state" actually behaves like root state.

The third level is a component's own providers list: with @Component({ providers: [X] }), every instance of that component gets its own X, and X goes away when the component is destroyed. That suits state that needs two independent copies on the same page (such as two separate book comparison panels). We will see this level again in the NgRx Signal Store post, with a store provided at component level.

linkedSignal: State Tied to Its Source but Still Writable

The catalog is now paged. The page number is local state: the user changes it with "Next". But it has to go back to 1 when the category changes; a user on page 2 of "classics" who switches to "Fantasy" should not stay on page 2 of a single-page category. The same goes for the cover dialog: it should close when the category changes. Both are state "derived from a source but also changeable by hand"; a computed cannot be written to, and a plain signal does not know its source. linkedSignal sits between the two:

export class CatalogPage {
  // URL state: ?category=classics, bound by withComponentInputBinding()
  readonly category = input<string>();

  // Local state that starts again from 1 whenever the category changes
  protected readonly page = linkedSignal({ source: this.category, computation: () => 1 });

  // Server state: a copy of the API's data, fetched again when the category or page changes
  protected readonly books = httpResource<BookPage>(() => {
    const params: Record<string, string | number> = { page: this.page() };
    const category = this.category();
    if (category) params['category'] = category;
    return { url: `${apiUrl}/books`, params };
  });

  // Global client state: shared with the header and the cart page
  protected readonly cart = inject(CartStore);

  // Local UI state: the cover dialog closes when the category changes
  protected readonly coverOf = linkedSignal<string | undefined, Book | null>({
    source: this.category,
    computation: () => null,
  });

When source changes, computation runs and its result becomes the signal's new value; in between, you can write any value with set. In the browser:

 0.1 s request: GET /books?page=1&category=classics
open classics                4 books, page 1 of 2 | dialog: closed
 0.8 s request: GET /books?page=2&category=classics
click Next                   4 books, page 2 of 2 | dialog: closed
open a cover                 4 books, page 2 of 2 | dialog: open
 2.3 s request: GET /books?page=1&category=fantasy
switch to Fantasy            1 books, page 1 of 1 | dialog: closed

"Next" brought page 2; when the category changed, the page went back to 1 and the dialog closed; httpResource sent a single request with the new parameters. You could also write this reset with an effect; the Angular documentation treats effects that copy a value from one signal to another as a sign that the state is in the wrong place and recommends computed or linkedSignal for those cases. With linkedSignal, the reset rule sits on the line where the page number is defined; there is no separate step to trigger. One more note: the page number is still in memory, so it is lost on reload and cannot be shared in a link. The fourth post will move it to the URL.

In Production

A store at the root does not reset itself when the user signs out. The cart has clear(); the sign-out flow should call it and also remove the copy in localStorage, otherwise the next user in the same browser sees the previous user's cart. Writing a reset method for every user-specific root store and calling it from the sign-out flow is the simplest way not to forget this.

Anything written to localStorage can be read by every script in that browser. A cart made of ISBNs, titles and quantities is fine; tokens, addresses or payment details should not go there. Don't forget that storage is a synchronous API either: writing a large state with JSON.stringify on every change keeps the main thread busy for frequently changing state. Negligible for a cart, a cost worth measuring for a list of thousands of rows.

For route-level stores, make the withAutoCleanupInjectors() decision early and tell the team. If this setting is turned on in an existing app, code that relies (knowingly or not) on route injectors staying alive changes behaviour: a selection left over from the previous visit no longer comes back. That is usually the desired behaviour, but it is a change worth writing in the release notes.

Common Pitfalls

Exposing the WritableSignal

Writing readonly items = signal<CartItem[]>([]) is shorter, but then readonly only prevents the field from being reassigned; the signal itself is writable by everyone. With the read-only view exposed, when someone who wants to add an "Empty cart" button to the cart page tries to write to the signal directly, the compiler stops them:

✘ [ERROR] TS2339: Property 'set' does not exist on type 'Signal<CartItem[]>'. [plugin angular-compiler]

    src/app/cart/cart-page.ts:32:20:
      32 │     this.cart.items.set([]);
         ╵                     ~~~

The error is exactly where we want it: the right fix is to add a clear() method to the store (ours already has one), not to change state by hand in the component.

An effect that writes the signal it reads

If this effect is added to the store with the idea "let's clean up lines whose quantity dropped to 0":

    // wrong: an effect that writes the signal it reads
    effect(() => {
      console.log('[cart] cleanup effect runs');
      this.#items.set(this.#items().filter((item) => item.quantity > 0));
    });

filter returns a new array every time; the effect replaces the signal it reads with a new reference, and that change runs the effect again. The first three seconds after opening the app:

after 1 s: effect ran 12513 times
after 2 s: effect ran 31295 times
after 3 s: effect ran 50641 times
header: TimeoutError

Angular threw no error; the effect ran 17 to 19 thousand times a second, the page was never rendered and the header could not be found even with a two-second wait. That is the concrete reason the Angular documentation advises against using effects to propagate state. The cleanup rule belongs in the method that makes the change: setQuantity already removes a line whose quantity drops below 1.

Copying a derived value with an effect

Even when it does not loop, a copy like effect(() => this.#total.set(...)) means using an effect to carry one signal into another. The copy stays one step behind: because the effect runs asynchronously, the copy read right after the method that made the change still holds the old total. A value that can be calculated is a computed; a value tied to a source but changeable by hand is a linkedSignal.

Assuming a route-level store's lifetime

The two outputs above are what the same code produced with two different router settings. Putting a store in providers does not by itself mean "it resets when the user leaves checkout"; without withAutoCleanupInjectors(), the store lives as long as the app.

A Small Experiment

★☆☆ Add an "Empty cart" button to the cart page and wire it to the store's clear(). After pressing the button, reload the page: was the copy in localStorage emptied too? Why?

★★☆ Write a shipping in CheckoutStore with linkedSignal: when the cart total goes over 500 TRY, the choice should go back to 'standard' (because shipping is free), but the user should still be able to pick 'express' by hand. Use the computation's previous parameter to keep the user's choice when the total was already over 500.

★★★ Add "the last book added" to the cart and show "Emma added" in the header for 3 seconds. Write it once with a signal in the store and an effect + setTimeout in the component; then try writing the same thing without an effect, with a timestamp kept in the store's method and a computed. Which one is easier to test, and which one is really an event?

In the next post we go back to the book list in the catalog, that is, to server state: httpResource's loading, error and reloading statuses, an optimistic update when adding a review and rolling it back on error, reload() after success, dependent resources with chain(), the same data being fetched twice by two components, and NgRx 22's resource extensions.