Week 17: State Management at Scale

The last week of Phase 4 asks a question every growing app eventually faces: once state is shared across more components than one service naturally holds, how do you keep it predictable? Not by defaulting to a heavyweight library — by knowing what problem each option actually solves.

Phase 4 of 7 Week 17 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design shared state that stays predictable as an app grows
  • Build a typed NgRx Signal Store and know when it's actually worth reaching for
  • Test stateful services with the same confidence as any other service

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.

cart-state.service.ts
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.

cart.store.ts
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.

using it — cart-summary.ts
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 single patchState convention 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.

entity-shaped state
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.

enabling devtools — app.config.ts (development only)
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:

cart-state.spec.ts
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:

cart.store.spec.ts
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

Hands-on

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:

  1. Create an AppStore Signal Store with three state slices: currentUser (from your Week 11 auth work), notifications (an array), and activeFilters (from any list/filter UI you've built).
  2. Add withComputed selectors: unreadNotificationCount, and isAuthenticated derived from currentUser.
  3. Add withMethods for every mutation — login/logout, mark-notification-read, and updating filters — each going through patchState.
  4. Replace every component that previously read this state from separate services with inject(AppStore) instead, and confirm nothing in the UI's behavior changed.
  5. Write at least three unit tests covering the computed selectors and one method each.
Hint

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?

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()?

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?

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?

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.