Lesson 27 of 30
Most Angular apps don't need a state library. A service with a private writable signal and a readonly public one is the modern default — until the app's needs genuinely outgrow it.
16 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
"State management" sounds like it needs a library, and for years in Angular it often meant reaching for something like NgRx by default, even for a shopping cart with three fields. That's changed. With signals as a first-class reactive primitive, an injectable @Injectable({ providedIn: 'root' }) service holding a private, writable signal and exposing a public, readonly one gives you a shared, reactive, app-wide store with no library, no boilerplate actions/reducers, and no extra concepts beyond what you already learned in the signals lesson.
The pattern is deliberately small: the service owns the one writable copy of the state, every component that injects the service sees the same signal instance (because providedIn: 'root' makes it an app-wide singleton), and nothing outside the service can call .set() or .update() on the public version — because it's read-only by construction, not by convention.
import { Injectable, signal, computed } from '@angular/core';
interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
// Private and writable — only this service can mutate it.
private readonly _items = signal<CartItem[]>([]);
// Public and read-only — consumers can read and react, never mutate directly.
readonly items = this._items.asReadonly();
readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.qty, 0)
);
readonly itemCount = computed(() => this._items().length);
addItem(item: CartItem) {
this._items.update((items) => [...items, item]);
}
removeItem(id: string) {
this._items.update((items) => items.filter((i) => i.id !== id));
}
clear() {
this._items.set([]);
}
}
Any component can inject(CartService) and read cartService.items() or cartService.total() — both stay live because computed() recalculates whenever _items changes, and every consumer sees the exact same underlying signal because the service is a singleton. There's no dispatch, no action type strings, no reducer switch statement — mutation happens through named methods (addItem, removeItem) that are the service's actual public API, which also means you get to name your state transitions something meaningful instead of routing everything through a single generic dispatch().
_items (unprefixed) is exposed directly, any component can call .set() on it from anywhere, and you've lost the one guarantee that made this pattern trustworthy: that only the service's own methods can change the state.providedIn: 'root' and re-providing the service locally. If a component's own providers array lists CartService again, that component (and its children) get a separate instance with its own separate signal — silently breaking the "one shared cart" assumption, with no error, just state that mysteriously doesn't sync.This is the same "hide the mutable thing behind a controlled interface" idea a closure gives you when a returned function keeps a private variable no outside code can touch directly — here it's a class field instead of a captured variable, but the guarantee is the same. It's also the direct analog of the "when does a dedicated state library earn its complexity" question this platform's own state-management-libraries lesson asks about Redux and friends in React — the answer converges from both directions: reach for the built-in tool first, and reach for a library when the coordination problem, not the data itself, gets genuinely hard.