# MVVM — Model View ViewModel

## The State Synchronisation Problem

When I added real-time features to the POS dashboard — order status updating live via WebSocket — I realised my old approach of manually wiring DOM updates was not going to scale. Each status change required finding the right element, checking if the component was still mounted, updating text, and toggling CSS classes. Every new piece of reactive data added another branch to maintain.

MVVM solves by making state the single source of truth. The **ViewModel** holds observable state. The **View** binds to that state and re-renders automatically whenever it changes. I do not imperatively update the DOM — I update data, and the view follows.

## Table of Contents

* [The Three Roles](#the-three-roles)
* [Data Binding: The Core Idea](#data-binding-the-core-idea)
* [TypeScript + React Example: Order Dashboard ViewModel](#typescript--react-example-order-dashboard-viewmodel)
* [Async Operations in the ViewModel](#async-operations-in-the-viewmodel)
* [ViewModel Testing](#viewmodel-testing)
* [MVVM vs MVP vs MVC](#mvvm-vs-mvp-vs-mvc)
* [When to Use MVVM](#when-to-use-mvvm)
* [Lessons Learned](#lessons-learned)

***

## The Three Roles

```
┌───────────────────────────────────────────────────────────┐
│                           View                            │
│  - Renders bound ViewModel state                          │
│  - Triggers ViewModel actions on user events              │
│  - No business logic, no direct model access              │
└───────────────────────────┬───────────────────────────────┘
          binds to / calls  │          observable state
                            ▼
┌───────────────────────────────────────────────────────────┐
│                        ViewModel                          │
│  - Observable state (reactive properties)                 │
│  - Exposes commands (actions/methods for the view)        │
│  - Transforms model data for display                      │
│  - Orchestrates async operations                          │
└───────────────────────────┬───────────────────────────────┘
            reads / updates │
                            ▼
┌───────────────────────────────────────────────────────────┐
│                          Model                            │
│  - Data, domain rules, persistence, API services          │
│  - No knowledge of ViewModel or View                      │
└───────────────────────────────────────────────────────────┘
```

The key difference from MVC and MVP: **the View and ViewModel are linked by two-way data binding** (or one-way data flow in React). The ViewModel never calls the View directly — it just updates state and the View reacts.

***

## Data Binding: The Core Idea

```
ViewModel state change  ──────►  View re-renders automatically
User interaction        ──────►  ViewModel action called
```

In React, this binding mechanism is `useState` / `useReducer` / external store (Zustand, MobX). In Vue it is `ref` / `reactive`. In WPF it is `INotifyPropertyChanged`. The mechanism differs; the concept is the same.

***

## TypeScript + React Example: Order Dashboard ViewModel

```typescript
// models/order.ts

export interface Order {
  id: number
  status: "open" | "confirmed" | "voided"
  total: number
  createdAt: string
  cashierName: string
}
```

```typescript
// services/orderService.ts

import { Order } from "../models/order"

export class OrderService {
  async fetchOrders(tenantId: string): Promise<Order[]> {
    const res = await fetch(`/api/orders?tenantId=${encodeURIComponent(tenantId)}`)
    if (!res.ok) throw new Error("Failed to fetch orders")
    return res.json()
  }

  async voidOrder(orderId: number, reason: string): Promise<void> {
    const res = await fetch(`/api/orders/${orderId}/void`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reason })
    })
    if (!res.ok) throw new Error("Failed to void order")
  }
}
```

```typescript
// viewmodels/useOrderDashboardViewModel.ts

import { useState, useEffect, useCallback } from "react"
import { OrderService } from "../services/orderService"
import { Order } from "../models/order"

// Display-ready derived type — the ViewModel transforms for the View
export interface OrderRow {
  id: number
  statusLabel: string
  statusColor: "green" | "gray" | "red"
  totalFormatted: string
  createdAtFormatted: string
  cashierName: string
  canVoid: boolean
}

interface OrderDashboardState {
  rows: OrderRow[]
  isLoading: boolean
  error: string | null
  voidingOrderId: number | null
}

function toRow(order: Order): OrderRow {
  return {
    id: order.id,
    statusLabel: order.status === "open" ? "Open" : order.status === "confirmed" ? "Confirmed" : "Voided",
    statusColor: order.status === "confirmed" ? "green" : order.status === "voided" ? "red" : "gray",
    totalFormatted: `${order.total.toFixed(2)} THB`,
    createdAtFormatted: new Date(order.createdAt).toLocaleString("th-TH"),
    cashierName: order.cashierName,
    canVoid: order.status !== "voided",
  }
}

export function useOrderDashboardViewModel(tenantId: string, service: OrderService) {
  const [state, setState] = useState<OrderDashboardState>({
    rows: [],
    isLoading: false,
    error: null,
    voidingOrderId: null,
  })

  const loadOrders = useCallback(async () => {
    setState(s => ({ ...s, isLoading: true, error: null }))
    try {
      const orders = await service.fetchOrders(tenantId)
      setState(s => ({ ...s, rows: orders.map(toRow), isLoading: false }))
    } catch (err) {
      setState(s => ({ ...s, error: "Could not load orders", isLoading: false }))
    }
  }, [tenantId, service])

  const voidOrder = useCallback(async (orderId: number, reason: string) => {
    setState(s => ({ ...s, voidingOrderId: orderId }))
    try {
      await service.voidOrder(orderId, reason)
      // Optimistically update the row
      setState(s => ({
        ...s,
        rows: s.rows.map(r => r.id === orderId
          ? { ...r, statusLabel: "Voided", statusColor: "red", canVoid: false }
          : r
        ),
        voidingOrderId: null,
      }))
    } catch {
      setState(s => ({ ...s, error: "Failed to void order", voidingOrderId: null }))
    }
  }, [service])

  useEffect(() => { loadOrders() }, [loadOrders])

  return { ...state, loadOrders, voidOrder }
}
```

```tsx
// components/OrderDashboard.tsx — the View

import React from "react"
import { useOrderDashboardViewModel } from "../viewmodels/useOrderDashboardViewModel"
import { OrderService } from "../services/orderService"

const service = new OrderService()

export function OrderDashboard({ tenantId }: { tenantId: string }) {
  const { rows, isLoading, error, voidingOrderId, loadOrders, voidOrder } =
    useOrderDashboardViewModel(tenantId, service)

  if (isLoading) return <div>Loading orders…</div>
  if (error)     return <div className="error">{error} <button onClick={loadOrders}>Retry</button></div>

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th><th>Status</th><th>Total</th><th>Cashier</th><th>Created</th><th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {rows.map(row => (
          <tr key={row.id}>
            <td>{row.id}</td>
            <td style={{ color: row.statusColor === "green" ? "var(--green)" : row.statusColor === "red" ? "var(--red)" : "inherit" }}>
              {row.statusLabel}
            </td>
            <td>{row.totalFormatted}</td>
            <td>{row.cashierName}</td>
            <td>{row.createdAtFormatted}</td>
            <td>
              {row.canVoid && (
                <button
                  disabled={voidingOrderId === row.id}
                  onClick={() => voidOrder(row.id, "Manager request")}
                >
                  {voidingOrderId === row.id ? "Voiding…" : "Void"}
                </button>
              )}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}
```

The View component contains no logic. It only reads ViewModel state and calls ViewModel actions. All data transformation (`toRow`) and state management lives in the ViewModel hook.

***

## Async Operations in the ViewModel

The ViewModel is the right place to manage loading and error state for async operations. A pattern I use consistently:

```typescript
// Generalised async operation state
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string }
```

Each async operation in the ViewModel transitions through these states. The View renders based on which state is active — no if-else chains guarding on multiple boolean flags.

***

## ViewModel Testing

Because the ViewModel is a React hook, test it with `@testing-library/react-hooks` (or `renderHook` from `@testing-library/react`):

```typescript
// viewmodels/useOrderDashboardViewModel.test.ts

import { renderHook, act, waitFor } from "@testing-library/react"
import { useOrderDashboardViewModel } from "./useOrderDashboardViewModel"
import { Order } from "../models/order"

const fakeOrders: Order[] = [{
  id: 1, status: "open", total: 350, createdAt: "2024-01-01T10:00:00Z", cashierName: "Alice"
}]

const makeMockService = (orders: Order[]) => ({
  fetchOrders: jest.fn().mockResolvedValue(orders),
  voidOrder: jest.fn().mockResolvedValue(undefined),
})

test("loads and transforms orders on mount", async () => {
  const service = makeMockService(fakeOrders)
  const { result } = renderHook(() =>
    useOrderDashboardViewModel("tenant_001", service as any)
  )

  await waitFor(() => expect(result.current.isLoading).toBe(false))

  expect(result.current.rows).toHaveLength(1)
  expect(result.current.rows[0].totalFormatted).toBe("350.00 THB")
  expect(result.current.rows[0].canVoid).toBe(true)
})

test("optimistically updates row on void", async () => {
  const service = makeMockService(fakeOrders)
  const { result } = renderHook(() =>
    useOrderDashboardViewModel("tenant_001", service as any)
  )
  await waitFor(() => expect(result.current.rows).toHaveLength(1))

  await act(async () => {
    await result.current.voidOrder(1, "Test void")
  })

  expect(result.current.rows[0].statusLabel).toBe("Voided")
  expect(result.current.rows[0].canVoid).toBe(false)
})
```

***

## MVVM vs MVP vs MVC

| Concern              | MVC                        | MVP                           | MVVM                           |
| -------------------- | -------------------------- | ----------------------------- | ------------------------------ |
| View updates         | Controller selects view    | Presenter calls view methods  | View binds to ViewModel state  |
| View knowledge       | May reference model data   | Only knows presenter          | Only knows ViewModel state     |
| View passivity       | Moderate                   | High (passive)                | High (declarative bindings)    |
| Testability          | Needs HTTP / context       | Presenter: pure class         | ViewModel: hook/class test     |
| Best fit             | Server-side MVC, REST APIs | Android, testable desktop UIs | React, Vue, reactive frontends |
| Two-way data binding | No                         | No                            | Yes (or reactive one-way flow) |

***

## When to Use MVVM

MVVM is the natural fit when:

* Using a **reactive UI framework** (React, Vue, Angular, Svelte, SwiftUI)
* The UI has **complex state** — loading states, multiple derived values, optimistic updates
* **Reusing the same ViewModel across different Views** (desktop + mobile, different themes)
* The team is larger and View/ViewModel separation enforces discipline

***

## Lessons Learned

* **The ViewModel should never import React DOM or component types.** If it does, it will not be portable or testable in isolation.
* **Transform data in the ViewModel, not in the View.** `totalFormatted`, `statusColor`, `canVoid` — all of these belong in the ViewModel so the View stays declarative.
* **One ViewModel per screen or feature area, not per component.** Sharing a ViewModel across components that belong to the same screen is fine; sharing it across unrelated screens is not.
* **Prefer one-way data flow in React.** React does not have two-way binding by default. MVVM in React means: state flows down from ViewModel to View, events flow up from View to ViewModel. Do not fight the framework.
