1. Signal-Based State Services (the "Poor Man's Store")
The simplest real state-management pattern in Angular is a plain injectable service holding a signal, with methods that update it — everything you already know from Week 9, applied to shared state instead of a single feature.
import { Injectable, signal, computed } from '@angular/core';
interface CartItem { id: string; price: number; quantity: number; }
@Injectable({ providedIn: 'root' })
export class CartState {
private items = signal<CartItem[]>([]);
readonly cartItems = this.items.asReadonly();
readonly itemCount = computed(() => this.items().reduce((n, i) => n + i.quantity, 0));
readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
addItem(item: CartItem) {
this.items.update((list) => [...list, item]);
}
removeItem(id: string) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}
.asReadonly() is the detail worth internalizing — it exposes a read-only
signal to consumers, so only CartState's own methods can actually mutate
items. Any component can read cartItems, but only this service
controls how it changes — the same encapsulation principle from Week 9, applied to
state.
This pattern alone covers the majority of real apps. Reach for something heavier only once you hit a problem it doesn't solve well — Section 3 covers exactly when that happens.
2. NgRx Signal Store Fundamentals
@ngrx/signals provides a more structured version of the same idea — state,
computed values, and methods declared together with a consistent shape, plus a
convention for how updates happen.
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
interface CartItem { id: string; price: number; quantity: number; }
interface CartStateShape { items: CartItem[]; }
export const CartStore = signalStore(
{ providedIn: 'root' },
withState<CartStateShape>({ items: [] }),
withComputed(({ items }) => ({
itemCount: computed(() => items().reduce((n, i) => n + i.quantity, 0)),
total: computed(() => items().reduce((sum, i) => sum + i.price * i.quantity, 0)),
})),
withMethods((store) => ({
addItem(item: CartItem) {
patchState(store, (state) => ({ items: [...state.items, item] }));
},
removeItem(id: string) {
patchState(store, (state) => ({ items: state.items.filter((i) => i.id !== id) }));
},
}))
);
patchState() is the one required way to update a Signal Store's state —
it's a small but deliberate constraint: every state change goes through one recognizable
call, which is what makes the DevTools story in Section 5 possible.
import { Component, inject } from '@angular/core';
import { CartStore } from './cart.store';
export class CartSummary {
cart = inject(CartStore);
// cart.items(), cart.itemCount(), cart.total(), cart.addItem(...) -- all directly available
}
3. When (and When Not) to Reach for NgRx/Redux-Style State
Compare directly against Section 1's plain service:
- Plain signal-based service fits: most apps, most of the time. Less ceremony, no new dependency, and every technique is one you already know from Week 9.
- Signal Store fits: state shared across many features with a real need for consistent structure — a large team wanting every feature's state to look the same, or state complex enough that
withComputed's composition and a singlepatchStateconvention genuinely earns its keep.
The failure mode worth avoiding is reaching for a Signal Store (or worse, full NgRx with actions/reducers/effects) for a feature with two pieces of state and one component that reads them — that's Section 1's pattern, with less code and nothing lost. State-management tooling should follow a real scaling problem, not precede one.
4. Entity State Patterns
"Entity state" means storing a normalized collection — a flat map keyed by ID, rather than a raw array — which makes lookups, updates, and removals by ID all O(1) instead of requiring an array scan.
interface ProductsStateShape {
entities: Record<string, Product>;
ids: string[]; // preserves order, since object key order isn't guaranteed to be stable
}
// Looking up a product by ID: O(1)
const product = state.entities[productId];
// Rendering in order:
const orderedProducts = state.ids.map((id) => state.entities[id]);
This matters once a collection grows past a trivial size — updating one product by ID in
a plain array means .map()-ing the whole array to find and replace it;
updating it in entity-shaped state is a single object-key write. @ngrx/signals
ships an entities feature implementing exactly this pattern, so you rarely
need to hand-write the normalization logic yourself.
5. DevTools & Time-Travel Debugging
Because every Signal Store update goes through patchState(), tooling can
intercept and log each one — Redux DevTools (originally built for Redux, and usable with
NgRx) shows a timeline of every state change, and lets you "time travel" by replaying
state back to any previous point.
import { withDevtools } from '@angular/core'; // conceptual -- actual API varies by NgRx version
// Wire the store (or a root-level state) through a devtools integration in dev builds only,
// so every patchState() call becomes a visible, inspectable entry in the Redux DevTools timeline.
The practical value: when a bug report says "the cart total was wrong," you can scrub
backward through the exact sequence of addItem/removeItem
calls that produced it — far faster than reproducing the bug from scratch by guessing at
user actions.
6. Testing Stateful Services
Both patterns from this lesson are testable with the same technique from Week 9 — a plain signal-based service needs nothing special at all:
import { CartState } from './cart-state.service';
describe('CartState', () => {
it('computes the total price correctly', () => {
const cart = new CartState();
cart.addItem({ id: '1', price: 10, quantity: 2 });
cart.addItem({ id: '2', price: 5, quantity: 1 });
expect(cart.total()).toBe(25);
});
});
A Signal Store needs TestBed, since signalStore() relies on
Angular's injector internally:
import { TestBed } from '@angular/core/testing';
import { CartStore } from './cart.store';
describe('CartStore', () => {
it('computes the total price correctly', () => {
TestBed.configureTestingModule({ providers: [CartStore] });
const store = TestBed.inject(CartStore);
store.addItem({ id: '1', price: 10, quantity: 2 });
store.addItem({ id: '2', price: 5, quantity: 1 });
expect(store.total()).toBe(25);
});
});
Either way, the assertions themselves read identically to any other signal-based test — state management doesn't need special-cased testing techniques once it's built on signals.
7. Hands-on Exercise
Refactor cross-cutting dashboard state into a Signal Store
Take the auth, notifications, and filters state scattered across your dashboard app (Weeks 9-11) and consolidate it.
Requirements:
- Create an
AppStoreSignal Store with three state slices:currentUser(from your Week 11 auth work),notifications(an array), andactiveFilters(from any list/filter UI you've built). - Add
withComputedselectors:unreadNotificationCount, andisAuthenticatedderived fromcurrentUser. - Add
withMethodsfor every mutation — login/logout, mark-notification-read, and updating filters — each going throughpatchState. - Replace every component that previously read this state from separate services with
inject(AppStore)instead, and confirm nothing in the UI's behavior changed. - Write at least three unit tests covering the computed selectors and one method each.
Before starting, write one sentence for why this state deserves a Signal Store rather than three separate plain services — if you can't articulate a real reason beyond "the lesson said to," that's worth sitting with, since Section 3's guidance applies to your own projects just as much as this exercise.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does .asReadonly() actually prevent, in Section 1's CartState?
What does .asReadonly() actually prevent, in Section 1's CartState?
It stops any consumer of cartItems from calling .set() or .update() on it directly — the returned signal only exposes the read side. State changes can only happen through CartState's own methods (addItem, removeItem), keeping the service the single place that controls how its state actually changes.
Q2
Why does a Signal Store require every state update to go through patchState()?
Why does a Signal Store require every state update to go through patchState()?
Routing every change through one recognizable function is what makes tooling like Redux DevTools possible — it gives every update a single point to intercept, log, and replay. It's a deliberate constraint traded for that inspectability, not an arbitrary API restriction.
Q3
Why is looking up a product by ID faster in entity-shaped state than in a plain array?
Why is looking up a product by ID faster in entity-shaped state than in a plain array?
An array lookup by ID requires scanning elements one at a time until a match is found — O(n) in the size of the array. An entity map keyed by ID (state.entities[id]) is a direct object-property access — O(1), regardless of how many entities exist. The tradeoff is needing a separate ids array to preserve display order, since object key order isn't a reliable ordering mechanism.
Q4
A small feature has two pieces of state, read by exactly one component. Per this lesson's guidance, should it use a Signal Store?
A small feature has two pieces of state, read by exactly one component. Per this lesson's guidance, should it use a Signal Store?
No — this is exactly the case Section 3 argues against. A plain signal-based service (or even a signal directly in the component, if it's not shared at all) handles this with less ceremony and no new dependency. Signal Store earns its complexity when state is genuinely shared across many features or a team wants enforced structural consistency — not by default for every piece of local state.