Modern Angular: Signals, Standalone Components and SSR · Routing, State, Performance and SSR
State Management with Signal Services
Manage feature state with a signal-based store service: private writable state, computed selectors, immutable updates, async actions with status, and persistence with effects.
What you'll build
A CartStore service that keeps its state in one private signal, exposes read-only computed selectors, changes state only through methods with immutable updates, tracks an async checkout's status and error, and persists the cart to localStorage with an effect. A component shows the cart, and the example drives a failed and then a successful checkout.
The idea
- Keep state private (
#state = signal(...)), exposecomputedselectors, and change it only through methods. Components can't modify it directly. - Update immutably (
update(s => ({ ...s, items: [...] }))) so signals notice changes and history is easy to reason about. - Model async work explicitly:
status: "idle" | "saving" | "error"plus the error message. - Use an
effectfor synchronization with the outside world (storage, analytics). - For large apps, NgRx SignalStore offers the same pattern with more structure; the principles are the same.
The code
import { Component, Injectable, computed, effect, inject, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing";
type CartItem = { name: string; price: number; qty: number };
type CartState = { items: CartItem[]; status: "idle" | "saving" | "error"; error?: string };
@Injectable({ providedIn: "root" })
class CheckoutApi {
failNext = true;
async checkout(items: CartItem[]): Promise<string> {
await new Promise((resolve) => setTimeout(resolve, 10));
if (this.failNext) { this.failNext = false; throw new Error("payment provider timed out"); }
return `order-${items.length}-items`;
}
}
@Injectable({ providedIn: "root" })
class CartStore {
#api = inject(CheckoutApi);
#state = signal<CartState>({ items: JSON.parse(localStorage.getItem("cart") ?? "[]"), status: "idle" });
readonly items = computed(() => this.#state().items);
readonly count = computed(() => this.items().reduce((n, item) => n + item.qty, 0));
readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.qty, 0));
readonly status = computed(() => this.#state().status);
readonly error = computed(() => this.#state().error);
constructor() {
effect(() => localStorage.setItem("cart", JSON.stringify(this.items())));
}
add(name: string, price: number) {
this.#state.update((s) => {
const existing = s.items.find((i) => i.name === name);
const items = existing
? s.items.map((i) => (i === existing ? { ...i, qty: i.qty + 1 } : i))
: [...s.items, { name, price, qty: 1 }];
return { ...s, items };
});
}
async checkout() {
this.#state.update((s) => ({ ...s, status: "saving", error: undefined }));
try {
const orderId = await this.#api.checkout(this.items());
this.#state.set({ items: [], status: "idle" });
return orderId;
} catch (e) {
this.#state.update((s) => ({ ...s, status: "error", error: (e as Error).message }));
return null;
}
}
}
@Component({
selector: "app-cart",
template: `
<p>{{ cart.count() }} items, total {{ cart.total() }}</p>
@if (cart.status() === "saving") { <p>Placing order...</p> }
@if (cart.error(); as error) { <p>Checkout failed: {{ error }}</p> }
`,
})
class CartView {
cart = inject(CartStore);
}
TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
const fixture = TestBed.createComponent(CartView);
const store = TestBed.inject(CartStore);
const show = async (label: string) => {
await fixture.whenStable();
const lines = [...fixture.nativeElement.querySelectorAll("p")].map((n: Element) => n.textContent!.trim());
console.log(`${label}: ${lines.join(" | ")}`);
};
store.add("Mug", 8.5);
store.add("Mug", 8.5);
store.add("Teapot", 24);
await show("after adding");
console.log("saved to localStorage:", localStorage.getItem("cart"));
const firstTry = store.checkout();
await show("checking out");
console.log("order:", await firstTry);
await show("after failure");
console.log("order:", await store.checkout());
await show("after success");
Output
after adding: 3 items, total 41
saved to localStorage: [{"name":"Mug","price":8.5,"qty":2},{"name":"Teapot","price":24,"qty":1}]
checking out: 3 items, total 41 | Placing order...
order: null
after failure: 3 items, total 41 | Checkout failed: payment provider timed out
order: order-2-items
after success: 0 items, total 0Run this on your computer — see “Get Started” for setup.
Common mistakes
- Exposing the writable signal (
state = signal(...)public) so any component can change anything. - Mutating arrays and objects in place (
items().push(...)); the signal's value is the same object, so nothing updates. - Storing derived values (count, total) as separate state that can drift; derive them with
computed. - Hiding async failures in
console.error; keep status and error in state so the UI can show them.
Key points
- Private writable state, public computed selectors, methods for changes.
- Immutable updates; derived data with computed.
- Explicit async status and errors; effects for persistence.
Further reading: Signals · NgRx SignalStore · Effects
Practice questions
Why keep the store's writable signal private?
Answer
So state changes only happen through the store's methods, keeping updates consistent and traceable; components read computed selectors.
Why must signal state be updated immutably?
Answer
Signals notify when their value changes by reference; mutating an object or array in place keeps the same reference, so dependents don't update.
How should a store represent async operations?
Answer
With explicit status (idle, saving, error) and error fields in state, so the UI can show progress and failures.
Open in the app to mark lessons complete, take notes and track your progress.