Skip to content

Angular Service State

When multiple, unrelated components need access to the same state, a shared, injectable service is usually the right tool, well before reaching for a heavier state management library. This lesson covers building one well.

Why Put State in a Service?

A singleton service, provided at the root injector, is naturally shared: every component that injects it receives the exact same instance, and therefore the exact same underlying state, without any additional wiring.

This makes services a lightweight, built-in "store" for state that multiple, otherwise unrelated components need to read and update, like a shopping cart, the current user, or UI-wide theme preferences.

@Injectable({ providedIn: 'root' })
export class CartService {
  private itemsSignal = signal<CartItem[]>([]);
  readonly items = this.itemsSignal.asReadonly();

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

  add(item: CartItem) {
    this.itemsSignal.update(items => [...items, item]);
  }
}

asReadonly() exposes items for reading everywhere, while keeping write access (itemsSignal.update) private to the service's own methods.

Building a Service-Based Store

@Injectable({ providedIn: 'root' })
export class FeatureStore {
  private stateSignal = signal(initialState);
  readonly state = this.stateSignal.asReadonly();

  someAction(payload: Payload) {
    this.stateSignal.update(state => ({ ...state, ...payload }));
  }
}
  • Keep the writable signal private; expose a readonly view (asReadonly()) for consumers.
  • Group related state into a single object signal when fields are logically related and often updated together.
  • Public methods (like add(), someAction()) are the only sanctioned way to change the state from outside.
  • computed() values derived from the private signal can be exposed publicly just like the state itself.

Service State Cheatsheet

Patterns for building a clean, shared state service.

Pattern Purpose
Private writable signal Keeps write access under the service's control
Public .asReadonly() signal Lets consumers read state without mutating it directly
Public methods (add, remove, ...) The sanctioned way to change state
computed() public getters Expose derived values without extra manual syncing
BehaviorSubject + .asObservable() RxJS-based alternative to the signal pattern above

A Complete Signal-Based Store Example

Combining a private writable signal, a public readonly view, computed derived values, and clear action methods produces a small, predictable store without any external library.

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

@Injectable({ providedIn: 'root' })
export class CartService {
  private itemsSignal = signal<CartItem[]>([]);
  readonly items = this.itemsSignal.asReadonly();
  readonly count = computed(() => this.itemsSignal().length);
  readonly total = computed(() =>
    this.itemsSignal().reduce((sum, i) => sum + i.price * i.qty, 0)
  );

  add(item: CartItem) {
    this.itemsSignal.update(items => [...items, item]);
  }

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

  clear() {
    this.itemsSignal.set([]);
  }
}

An RxJS-Based Alternative

The same pattern works with a BehaviorSubject instead of a signal, useful in codebases still primarily RxJS-based, or when the state naturally needs RxJS operators (like debouncing updates).

@Injectable({ providedIn: 'root' })
export class CartService {
  private itemsSubject = new BehaviorSubject<CartItem[]>([]);
  readonly items$ = this.itemsSubject.asObservable();

  add(item: CartItem) {
    this.itemsSubject.next([...this.itemsSubject.value, item]);
  }
}

Consuming Service State in Components

Components inject the service and read its public state directly, no manual wiring, subscriptions, or props drilling required, even for completely unrelated components elsewhere in the tree.

@Component({ selector: 'app-cart-badge', standalone: true, template: '{{ cart.count() }}' })
export class CartBadgeComponent {
  cart = inject(CartService);
}

Common Mistakes

  • Exposing the writable signal or Subject directly instead of a readonly view, letting any component mutate shared state unpredictably.
  • Duplicating the same state in multiple services instead of a single source of truth.
  • Putting component-local state (like a single form's draft values) into a shared service unnecessarily.
  • Forgetting providedIn: 'root', accidentally creating multiple, disconnected instances of what was meant to be shared state.

Key Takeaways

  • A singleton, root-provided service is a lightweight, built-in way to share state across unrelated components.
  • Keep write access private; expose read-only signals (or .asObservable()) publicly.
  • Public methods on the service are the only sanctioned way to change shared state.
  • The same pattern works with either signals or BehaviorSubject, choose based on your codebase's conventions.

Pro Tip

Design service state APIs around clear, named actions (add(), remove(), clear()) rather than exposing a generic setState() method; it makes every state change traceable to a specific, intentional call site.