Skip to content
Angular ng services 4 min read

Stateful Services with Signals

Angular services have always been the natural home for shared state, but historically you needed RxJS BehaviorSubjects and a lot of boilerplate to expose that state reactively. Signals collapse all of that into a few lines: a service holds a private writable signal, exposes a read-only view, and derives values with computed(). The result is a fully reactive store that components can consume without subscriptions, manual change detection, or async pipes. This pattern is the modern, idiomatic way to manage application state in Angular 17/18/19.

Why signals belong in services

A signal is a reactive value container. When you read a signal inside a template or a computed(), Angular records the dependency and re-renders or recomputes only when the value changes. Because a singleton service lives for the lifetime of the application, it is the perfect place to own long-lived state. Combining the two gives you a store that is:

  • Synchronous and glitch-free — reads always return the current value, with no timing surprises.
  • Granular — components only re-render when the specific signal they read changes.
  • Subscription-free — no subscribe/unsubscribe lifecycle to manage and no memory leaks.

A minimal signal store

The core pattern is encapsulation: keep the writable signal private, and expose a read-only Signal<T> plus mutation methods. Consumers can read but never write directly, which keeps state transitions centralized and predictable.

import { Injectable, computed, signal } from '@angular/core';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  qty: number;
}

@Injectable({ providedIn: 'root' })
export class CartStore {
  // Private writable source of truth.
  private readonly _items = signal<CartItem[]>([]);

  // Public read-only views.
  readonly items = this._items.asReadonly();

  // Derived state recomputes automatically.
  readonly count = computed(() =>
    this._items().reduce((sum, i) => sum + i.qty, 0),
  );

  readonly total = computed(() =>
    this._items().reduce((sum, i) => sum + i.price * i.qty, 0),
  );

  readonly isEmpty = computed(() => this._items().length === 0);

  add(item: Omit<CartItem, 'qty'>): void {
    this._items.update((items) => {
      const existing = items.find((i) => i.id === item.id);
      if (existing) {
        return items.map((i) =>
          i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
        );
      }
      return [...items, { ...item, qty: 1 }];
    });
  }

  remove(id: string): void {
    this._items.update((items) => items.filter((i) => i.id !== id));
  }

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

Tip: Always mutate signals immutably inside update() — return a new array or object rather than mutating in place. signal() uses referential equality by default, so an in-place mutation will not trigger downstream recomputation or rendering.

Consuming the store in a component

Inject the store with inject() and read its signals directly in the template. No async pipe, no subscription. The new control-flow syntax pairs cleanly with computed signals.

import { Component, inject } from '@angular/core';
import { CartStore } from './cart.store';

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    @if (cart.isEmpty()) {
      <p>Your cart is empty.</p>
    } @else {
      <ul>
        @for (item of cart.items(); track item.id) {
          <li>
            {{ item.name }} x {{ item.qty }} — {{ item.price * item.qty }}
            <button (click)="cart.remove(item.id)">Remove</button>
          </li>
        }
      </ul>
      <p>Items: {{ cart.count() }} | Total: {{ cart.total() }}</p>
      <button (click)="cart.clear()">Clear cart</button>
    }
  `,
})
export class CartComponent {
  readonly cart = inject(CartStore);
}

Calling the store from multiple components shares the same state instance, because providedIn: 'root' makes it a singleton.

Output:

Items: 3 | Total: 87

Reacting to state changes with effect()

Sometimes you need a side effect when state changes — persisting to localStorage, logging, or syncing to a server. Use effect(), which re-runs whenever any signal it reads changes. Effects must run in an injection context, so create them in the constructor or via a field initializer.

import { Injectable, effect, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>(
    JSON.parse(localStorage.getItem('cart') ?? '[]'),
  );
  readonly items = this._items.asReadonly();

  constructor() {
    // Persist on every change.
    effect(() => {
      localStorage.setItem('cart', JSON.stringify(this._items()));
    });
  }

  // ...mutation methods as above
}

Warning: Do not call .set() or .update() on a signal from inside an effect() unless you opt in with { allowSignalWrites: true }. Writing to signals inside effects creates feedback loops that Angular blocks by default to prevent infinite cycles.

Signal store vs. RxJS service

ConcernSignal serviceBehaviorSubject service
Read current valuestore.value() synchronouslysubject.getValue()
Template bindingdirect call, no pipeasync pipe required
Derived statecomputed()combineLatest + map
Subscription cleanupnone neededmanual or takeUntilDestroyed
Async streams (HTTP, websockets)wrap with toSignal()native

Signals win for synchronous UI state; RxJS still shines for complex async event streams. The two interoperate via toSignal() and toObservable() from @angular/core/rxjs-interop, so you can mix them where each fits best.

Best Practices

  • Keep the writable signal private and expose only asReadonly() views so all mutations flow through named methods.
  • Derive everything you can with computed() instead of storing redundant state — derived values stay consistent automatically.
  • Always update signals immutably; never mutate the existing array or object reference in place.
  • Scope global app state with providedIn: 'root'; for state that should reset per route or per feature, provide the service at the component or route level instead.
  • Use effect() for side effects only (persistence, logging), and avoid writing back to signals inside effects.
  • Bridge async sources with toSignal() rather than manually subscribing inside the service constructor.
Last updated June 14, 2026
Was this helpful?