javascript

Optimizing Your Cart with Signals: Smarter State, Better Debugging

Sonu Kapoor

Sonu Kapoor on

Optimizing Your Cart with Signals: Smarter State, Better Debugging

In the first two parts of this series, we introduced Angular Signals and built a reactive shopping cart. Our CartService already supports core operations like adding, removing, and clearing items, as well as computing total price and item count using computed(). All of this was done without touching RxJS, subscriptions, or change detection hacks.

But a real-world cart does more than tally up numbers. It validates inventory, applies discount codes, persists data across sessions, and integrates cleanly with monitoring and debugging tools.

In this third and final article, we’ll extend our cart with production-ready features and show how Signals not only simplify reactivity, but also enhance observability, performance, and developer experience.

We'll introduce Angular’s effect() function to perform side effects like syncing to localStorage. We’ll handle derived state conditions like discount logic and stock checks. And we’ll wrap up by reviewing the final architecture to see how everything fits together cleanly.

Applying Discounts with Reactive Computed State

The ability to apply a discount code is a common cart feature. We’ll implement this by tracking the current code as a signal(), and then deriving the discountAmount and finalTotal values based on that code and the cart's total.

First, we'll add a new signal to the CartService:

ts
_discountCode = signal<string | null>(null);

Then expose a method to update it:

ts
applyDiscount(code: string) { this._discountCode.set(code); }

Next, we define a reactive computed value for the discount amount.

Listing 1: Computing the Discount Amount

Let’s assume a simple business rule: "SAVE10" gives 10% off, and other codes give nothing.

ts
readonly discountAmount = computed(() => { const code = this._discountCode(); const total = this.total(); if (code === 'SAVE10') { return total * 0.1; } return 0; });

From this, we derive the final price the user will pay:

ts
readonly finalTotal = computed(() => this.total() - this.discountAmount());

This is what makes Signals so powerful: you model real business logic as pure reactive expressions. If the cart total or the discount code changes, these values update immediately and automatically. There’s no need to re-run methods or patch together combineLatest chains.

Validating Inventory in Real Time

Another crucial feature is validating whether items in a cart are still in stock. Let’s assume we fetch the current inventory from a product service and expose it as a signal:

ts
readonly inventory = signal<Record<string, number>>({});

We can inject this ProductService into the CartService and reactively compute whether the cart is valid.

Listing 2: Inventory Validation with computed()

This reactive check becomes incredibly useful:

ts
readonly isValid = computed(() => { const items = this._items(); const stock = this.productService.inventory(); return items.every(item => { const available = stock[item.id] ?? 0; return item.qty <= available; }); });

You can disable the checkout button, show warnings, or highlight out-of-stock items; all with zero manual subscriptions or additional logic.

And because it’s computed, the check only re-evaluates when either _items or inventory changes. This avoids unnecessary work and fits naturally into Angular’s change detection strategy.

Persisting the Cart to localStorage with effect()

One of the most practical use cases for effect() is syncing state to localStorage. Whenever the cart changes, we want to serialize it and persist it.

Listing 3: Saving Cart State with effect()

We can do this by using an effect in the CartService constructor:

ts
constructor() { effect(() => { const items = this._items(); localStorage.setItem('cart', JSON.stringify(items)); }); }

That’s it. This runs automatically whenever _items() changes. Because effect() tracks dependencies on first execution, Angular knows to re-run this logic only when needed. There’s no need to debounce, throttle, or manage cleanup.

To restore the cart on load, you can hydrate it like this:

ts
private hydrateFromStorage() { const saved = localStorage.getItem('cart'); if (saved) { try { const parsed = JSON.parse(saved); this._items.set(parsed); } catch { // ignore invalid storage state } } }

Call this once from the constructor, before registering the effect.

This kind of logic is normally scattered across lifecycle hooks or abstracted into effect libraries. With Angular’s native Signals, it becomes first-class and clean.

Debugging is Easier with Signals

One of the hidden costs of observables is their runtime complexity. When something breaks, you’re often stuck digging through custom pipes, nested subscriptions, and unclear emission order.

With Signals, the control flow is easier to follow. Everything is synchronous, declarative, and traceable by reading code. You can log Signal values directly without worrying about emissions or side effects.

Listing 4: Adding a Debugging Effect

Let's see how we can add a debugging effect:

ts
constructor() { effect(() => { console.log('Cart updated:', this._items()); console.log('Total:', this.finalTotal()); console.log('Discount:', this.discountAmount()); }); }

Because these reads are reactive, you’ll only see logs when the values actually change. There’s no need to guess when a stream will emit, or worry about memory leaks from dangling subscriptions.

And since Signals work naturally with OnPush change detection, you won’t find yourself poking at ChangeDetectorRef.markForCheck() or async pipe timing issues. Your app updates when, and only when, it needs to.

Performance: Fine-Grained Recalculation

Let’s talk about performance. In large apps, it’s not enough to be reactive; you need to be selective. You want updates to happen only when something relevant has changed.

Signals achieve this through fine-grained dependency tracking. If a computed value depends on three signals, it only re-runs when one of those three actually changes. Angular tracks these dependencies at runtime, during the first execution of the function.

This means derived state like discountAmount and finalTotal doesn't recompute unless the total or the discount code changes. Unlike template expressions, which are re-evaluated on every change-detection cycle (unless the cycle is skipped for that component by OnPush), Signals re-evaluate only when an upstream dependency updates.

This behavior scales beautifully. You can derive pricing summaries, cart warnings, button states, and UI fragments without adding performance penalties.

And since the update model is pull-based, you avoid unnecessary emissions when nothing has changed. You get full reactivity, without the noise.

Final Architecture Review

Let’s take a step back and review what we’ve built across the series.

Our final CartService now handles:

  • Reactive state with signal() for cart items and discount codes
  • Derived values like total, count, discount amount, and final total
  • Business rules like inventory checks using injected services
  • Persistence to localStorage with effect()
  • Testability without subscriptions or marble syntax
  • Observability with simple logging or hooks

All of this exists in a single service. No store, no reducer maps, no action types, and no boilerplate. Just direct, reactive logic built on Angular’s own primitives.

Even better, the entire cart architecture remains template-friendly, OnPush-compatible, and future-proof. You can easily extend it to support quantity editing, wishlist features, or checkout flows all using the same architectural pattern.

This approach encourages clear boundaries, functional state updates, and encapsulated logic: all of which align with scalable frontend development.

Conclusion: Better State, Better Apps

In this series, we built a complete, scalable shopping cart using nothing but Angular Signals. No external store libraries. No RxJS overhead — not even a single observable. Just modern Angular, used to its full potential.

Angular Signals are more than just a syntactic shift, they’re a new mental model. One that moves away from passive data streams and toward synchronous, transparent, and fine-grained reactivity.

By using Signals in your services, you gain complete control over your app’s state logic without sacrificing testability, observability, or performance. You write less code, but more meaningful code. You ship features faster, with fewer bugs. And when things go wrong, you can trace what happened just by reading your service.

If you're using monitoring tools, Signals give you a much clearer picture of what changed, when it changed, and why downstream effects occurred. Combined with effect() and Angular’s runtime hooks, this opens the door to incredibly powerful diagnostics and logging, without extra dependencies.

Now imagine what else you could build.

Happy coding!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Sonu Kapoor

Sonu Kapoor

Guest author Sonu Kapoor is a seasoned Senior Software Engineer and internationally recognized speaker with over two decades of experience in building high-performance web applications. He is a Microsoft MVP (2005–2010, 2024) and a Google Developer Expert for Angular, as well as a trusted contributor to the Angular Framework, where he has helped shape the future of frontend development. Besides that, he has published two books.

All articles by Sonu Kapoor

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps