Building a Scalable Multi-Cart Checkout System in Angular with RxJS for Feature-Scoped State
At Halodoc, we operate a large-scale healthcare platform with over 1M+ daily frontend interactions — spanning online consultations, lab bookings, pharmacy orders, and complex IP case workflows.
As our frontend footprint grew, we began facing common pain points:
- Redundant API calls across summary cards, tables, and side panels
- Inconsistent UI state when data changed from multiple sources
- Tightly coupled views, making even small changes risky
- Debugging nightmares with state scattered across components and services
In early projects, state was passed through @Input()/@Output() or shared via services. But as complexity grew:
- Component chains made changes harder to reason about
- No single source of truth led to desynced views
- Duplicate fetches wasted API calls
- Testing became painful with state tied to view logic or internal service behavior
We needed a scalable solution for local and shared state that avoided the overhead of full-scale tools like NgRx.
What is NgRx?
NgRx is a robust state management library for Angular, inspired by Redux. It provides:
- Predictable state containers
- Immutability by default
- Time-travel debugging via DevTools
- Deep integration with large-scale Angular applications
While these features are powerful for managing global application state, they can become counterproductive for feature-scoped scenarios — such as dashboards, carts, filters, or forms — where simplicity and speed often take precedence over global consistency.
At Halodoc, we experienced this firsthand. Implementing a relatively simple Cart module with NgRx required 4–6 files and increased onboarding time by over 20% compared to our newer, RxJS-based store pattern. This experience led us to reconsider our approach to local state — prioritizing cohesion, clarity, and faster iteration.
Development Overhead with NgRx
1. Boilerplate for Small Features
NgRx builds on top of RxJS with well-designed abstractions — Actions, Reducers, Effects, and Selectors — that enable testability and scalability. However, even for minor features, this structure often demands multiple files and layers.
While such rigor is warranted for shared global state, we found it too verbose for isolated flows like dashboards, carts, or filters — where fewer moving parts would suffice.
In these cases, the boilerplate added more overhead than value — especially when lightweight RxJS-powered stores could deliver the same results with simpler, localized code.
2. Conceptual Load Without ROI
NgRx introduces several powerful abstractions — Actions, Reducers, Effects, Memoized Selectors, and StoreModule configuration. While this is valuable for complex, cross-cutting state, it adds cognitive overhead for feature-scoped needs like dashboards, carts, or filters.
This isn’t about team capability — even experienced engineers felt friction using NgRx where a stream-based service or RxJS store would have been more appropriate.
3. Fragmented Business Logic
With logic split across multiple files and layers (e.g., reducers, effects, selectors), tracing the flow of a feature often requires switching between different contexts. This fragmentation can slow down debugging, testing, and iteration cycles.
4. Not Optimized for Local/Feature State
NgRx excels at managing shared, application-wide state — but not all state needs that level of structure. In cases like cart logic or filter selections, which are confined to a single module or screen, the architectural weight of NgRx becomes difficult to justify.
Instead, we’ve leaned into RxJS-powered store services, which offer better alignment with feature encapsulation, faster development cycles, and simpler code.
What follows is a distilled, production-ready pattern we now use across Halodoc — from dashboards to booking flows to case timelines.
Our Approach: RxJS-Powered Angular Stores
We adopted a lightweight, reactive pattern using Angular services and RxJS's BehaviorSubject.
Each feature (cart, dashboard, activity log) has its own store service:
- Internal state is held privately using
BehaviorSubject - Exposed to components as read-only Observables
- State updates are done through clearly named methods
This pattern now powers dashboards, case timelines, carts, and more.
🛒 Demo: Health Store App with Multi-Store Carts
To demonstrate our approach, let’s walk through a working health store app. It supports up to 2 carts (one per vertical), reflects a real-time dashboard summary, and logs every user interaction and many more
- Up to 3 verticals (Medicines, Lab, Home Care) — each with its own cart
- A dashboard showing total revenue across carts
- A sticky header showing total items + current filter
- An activity panel logging every add/remove/update
The entire app is driven by RxJS-based state stores.
🧠 BehaviorSubject as a Private Store
Each feature (cart, dashboard, activity log) has its own service acting as a state container. We use BehaviorSubject to hold state and emit updates:
Why? BehaviorSubject provides the current value (via .value) and emits new values to all subscribers. This allows us to keep the source private (_state) and expose only the observable (cartState$) publicly.
This protects state integrity and enforces a clear update path.
🎯 Selectors for Derived State
Using RxJS map(), we compute derived values like total items count, subtotal per cart, and the grand total — reactively.
Each selector becomes a standalone observable. UI components like the header or dashboard simply subscribe using AsyncPipe.
📝 Tracking Activity with pairwise()
To track and log what changed in the cart, we use the powerful pairwise() operator, which emits both the previous and current state:
This drives the activity panel — a centralized view of every cart mutation: add, remove, or update.
Components Stay Dumb & Reactive
UI components don’t manage any internal state. They simply render what comes from the store:
Business logic (like limiting 2 carts or computing totals) is encapsulated in the store. This keeps components highly testable and focused on rendering.
🔑 Key Principles:
- Use
BehaviorSubjectto maintain internal state - Expose state slices via read-only
Observable - Use
RxJSoperators to derive computed states like totals, counts, etc. - Make all updates via clear, intention-named methods (
addProduct,updateFilter, etc.)
🔍 Try the Example Yourself:
How Real-Time UI Sync Works Across Components
One of the biggest advantages of using BehaviorSubject-based stores are that any component can react to state changes instantly — without tightly coupling them together or manually propagating events.
Here's how it works behind the scenes:
🌐 Centralized State (Single Source of Truth)
Each feature (like the cart or dashboard) has its own Store service backed by a BehaviorSubject, which holds the current state. For example:
This ensures:
- There's only one place where truth lives
- You always have access to the latest value via
.valueor.snapshot
📡 Exposed as Observables
We expose read-only observable streams using .asObservable() so that any component can subscribe and react to changes:
This is powerful because subscribing doesn’t mutate anything — it's passive and safe.
Even Derived State Is Reactive
For example, DashboardStore listens to CartStore.grandTotal$ to recompute its revenue. This means the dashboard gets updated when the cart changes — without any explicit connection between the components.
So:
- Update the cart
- Dashboard updates
- Header badge updates
- Activity panel logs it
—all without a single manual refresh.
NgRx vs RxJS Store — Compared with our cart store example
NgRx (Redux-style State)
How it works:
You define actions, reducers, selectors, and maybe even effects.
To use it in your component:
✅ Pros:
- Great devtools/debugging support
- Encourages immutable, testable state flows
🚫 Cons:
- 4+ files for one feature
- Harder to trace logic
- Boilerplate-heavy
- Not intuitive for simple use cases
RxJS Store (Our Pattern)
How it works:
You use an Angular service with a BehaviorSubject and expose reactive selectors.To use it in your component:
✅ Pros:
- Only 1 file for everything
- No learning curve — just services + RxJS
- Business logic is right in front of you
- Easier to test and debug
- Great for modular/feature-level state
- Real-time updates across components without boilerplate
🚫 Cons:
- No built-in devtools/history (though you can build your own logs)
- You need to handle immutability yourself
Which Should You Choose?
| Use Case | RxJS Store | NgRx |
|---|---|---|
| Simple dashboard / cart / table | ✅ | ❌ |
| Feature-scoped state | ✅ | ⚠️ |
| Enterprise-scale global state | ⚠️ | ✅ |
| Time-travel debugging needed | ❌ | ✅ |
| Teams new to state management | ✅ | ❌ |
Conclusion: Finding the Right Balance
At Halodoc, our state management evolution was guided by the need for developer productivity and application performance. While NgRx excels at handling complex global state, we found a lightweight, RxJS-based approach better suited for feature-scoped needs.
By moving to RxJS stores, Halodoc reduced state boilerplate by 80%, cut developer onboarding by 30%, and delivered sub-100 ms cart updates across three concurrent carts.
Try integrating feature-scoped RxJS stores in your next Angular feature to boost development speed and maintainability.
Sounds like the kind of challenge you’re looking for? Join us.
At Halodoc, we solve complex engineering problems that have a real-world impact on millions of lives. The reactive pattern you just read about is a snapshot of how we approach development: we favor clean, scalable, and pragmatic solutions that empower our teams to build great products.
If you’re a passionate engineer who thrives on building robust systems, loves tackling challenges at scale, and wants to be part of a mission-driven company that’s revolutionizing healthcare, we want to hear from you.
We’re hiring for roles across our engineering teams. Come build the future of healthcare with us.
➡️ Explore our open roles and apply today! [Link]
About Halodoc
Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.
Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.
We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions — and we remain steadfast in our journey to simplify healthcare for all Indonesians.