MVVM components in Angular
Angular structures applications around Components and Services, which is how it realizes the MVVM model:
1. View (Component’s template)
This is the user interface, defined with HTML and CSS. In Angular, it is the template file (e.g., *.component.html
) of a component. The View is responsible for rendering data and listening to user events such as clicks and input.
2. ViewModel (Component class)
This is the logic of the component, defined in the TypeScript file (e.g., *.component.ts
). The ViewModel contains properties to store data and methods to handle logic. It has no direct knowledge of the DOM and interacts with the View via Angular’s data binding mechanisms.
3. Model (Services)
The Model holds data and business logic. In Angular, we typically use Services for this role. Services are designed to fetch data from external sources (such as APIs), persist data, and perform complex computations. Components (the ViewModel) use Services to retrieve and update data, keeping business logic completely separate from the UI.
Disadvantages when using MVVM in Angular
In addition to the advantages and disadvantages of MVVM listed at MVVM, using MVVM in Angular also has some limitations:
- Learning curve: To use MVVM effectively in Angular, you need a solid understanding of framework concepts and mechanisms like
@Component
, services, observables, and change detection. - Complex state management: MVVM by itself does not define a clear way to manage global state. When one component changes state and another needs to react, you might resort to ad-hoc solutions such as EventEmitter or Subject, which can create data flows that are hard to trace and debug.
Example
Assume we have a shopping application with two components:
- ProductListComponent: Displays a list of products with an “Add to cart” button for each item.
- CartStatusComponent: Displays the total number of items in the cart.
When a user adds a product to the cart from ProductListComponent
, the CartStatusComponent
must be updated. Using only MVVM, this becomes complicated because the two components do not have a direct parent-child relationship to easily pass data.
1. ProductListComponent
This component will emit an event when the user adds a product.
import { Component, EventEmitter, Output } from '@angular/core'
@Component({ selector: 'app-product-list', template: ` <h2>Products</h2> <button (click)="addToCart.emit()">Add to cart</button> `})export class ProductListComponent { @Output() addToCart = new EventEmitter<void>()}
2. CartStatusComponent
This component needs to listen for the event from ProductListComponent
to update its state.
import { Component } from '@angular/core'
@Component({ selector: 'app-cart-status', template: ` <h2>Cart</h2> <p>Number of items: {{ count }}</p> `})export class CartStatusComponent { count = 0
// Method to update the count updateCount(): void { this.count++ }}
3. ParentComponent
To enable communication between these two components, we need a parent component to act as a mediator. The parent will listen to the event from ProductListComponent
and call a method on CartStatusComponent
.
import { Component, ViewChild } from '@angular/core'import { CartStatusComponent } from './cart-status/cart-status.component'
@Component({ selector: 'app-root', template: ` <h1>Shopping App</h1> <app-product-list (addToCart)="onAddToCart()"></app-product-list> <app-cart-status></app-cart-status> `})export class AppComponent { @ViewChild(CartStatusComponent) cartStatusComponent!: CartStatusComponent
onAddToCart(): void { this.cartStatusComponent.updateCount() }}
Problem analysis
-
Complex data flow: The data (item count) is not shared directly. It must go through a complicated path:
ProductListComponent
emits an event ->AppComponent
listens ->AppComponent
calls a method onCartStatusComponent
via@ViewChild
. -
Reduced component independence:
AppComponent
now needs to know internal details ofCartStatusComponent
(specifically itsupdateCount
method), which violates separation of concerns. -
Hard to scale: If you add another component that needs to know the cart item count, you must modify
AppComponent
again to manage the new data flow.
Solution
To address this, developers commonly use a shared Service for state management. This service acts as a central place where components can share data without directly communicating with each other.
1. CartService
This service serves as the shared Model that manages the cart state.
import { Injectable } from '@angular/core'import { BehaviorSubject } from 'rxjs'
@Injectable({ providedIn: 'root'})export class CartService { private itemCountSubject = new BehaviorSubject<number>(0) itemCount$ = this.itemCountSubject.asObservable()
addToCart(): void { const currentCount = this.itemCountSubject.value this.itemCountSubject.next(currentCount + 1) }}
2. ProductListComponent (product-list.component.ts
)
Now it only needs to call a method on CartService
.
import { Component } from '@angular/core'import { CartService } from '../cart.service'
@Component({ selector: 'app-product-list', template: ` <h2>Products</h2> <button (click)="cartService.addToCart()">Add to cart</button> `})export class ProductListComponent { constructor(public cartService: CartService) {}}
3. CartStatusComponent (cart-status.component.ts
)
It only needs to subscribe to changes from CartService
.
import { Component } from '@angular/core'import { CartService } from '../cart.service'import { Observable } from 'rxjs'
@Component({ selector: 'app-cart-status', template: ` <h2>Cart</h2> <p>Number of items: {{ itemCount$ | async }}</p> `})export class CartStatusComponent { itemCount$: Observable<number>
constructor(private cartService: CartService) { this.itemCount$ = this.cartService.itemCount$ }}
With this approach, components do not need to know about each other. They only need to know about the shared service, making state management in complex applications easier and more structured.