MVVM: Model View ViewModel

MVVM (Model-View-ViewModel) — архитектурный паттерн, в котором ViewModel содержит логику представления и состояние UI, а View автоматически обновляется при изменении ViewModel через механизм привязки данных (data binding).

Зачем нужно

MVVM решает главную проблему MVC и MVP — ручную синхронизацию View и данных. В MVVM это автоматизировано: ViewModel хранит observable-состояние, View подписана и обновляется реактивно. React с его useState и useReducer следует духу MVVM: компонент автоматически перерендеривается при изменении state. Понимание MVVM помогает проектировать архитектуру компонентов с разделением UI-логики и бизнес-логики.

Где используется

  • WPF, Xamarin, SwiftUI, Jetpack Compose — нативные платформы
  • Vue.js — реактивность через data binding (очень близко к MVVM)
  • Angular — двусторонний binding через [(ngModel)]
  • React с MobX — observable store + автоматический ре-рендер
  • React + Zustand/Jotai — однонаправленный вариант MVVM

Структура MVVM

┌──────────┐    Data Binding    ┌────────────┐    ┌───────┐
│   View   │◄──────────────────►│ ViewModel  │───►│ Model │
│          │    (реактивно)     │            │    │       │
└──────────┘                   └────────────┘    └───────┘

View         — компоненты, шаблоны, HTML
ViewModel    — состояние UI, команды, форматирование
Model        — данные, бизнес-логика, API

MVVM в React: компонент как View + ViewModel

// ViewModel как кастомный хук
// Инкапсулирует UI-логику, тестируется без рендера

interface Product {
  id: string;
  name: string;
  price: number;
  discount?: number;
}

// ViewModel
function useProductViewModel(productId: string) {
  // Состояние UI (ViewModel state)
  const [quantity, setQuantity] = useState(1);
  const [isAddingToCart, setIsAddingToCart] = useState(false);

  // Данные из Model (API/store)
  const { data: product, isLoading, error } = useProduct(productId);

  // Вычисляемые значения (форматирование для View)
  const formattedPrice = product
    ? new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' })
        .format(product.price * (1 - (product.discount ?? 0)))
    : '';

  const hasDiscount = Boolean(product?.discount);
  const canAddMore = quantity < 99;
  const canAddLess = quantity > 1;

  // Команды (ViewModel actions)
  const incrementQuantity = () => setQuantity(prev => Math.min(prev + 1, 99));
  const decrementQuantity = () => setQuantity(prev => Math.max(prev - 1, 1));

  const addToCart = async () => {
    if (!product) return;
    setIsAddingToCart(true);
    try {
      await cartApi.addItem({ productId, quantity });
    } finally {
      setIsAddingToCart(false);
    }
  };

  return {
    // Данные для View
    product,
    isLoading,
    error,
    formattedPrice,
    hasDiscount,
    quantity,
    canAddMore,
    canAddLess,
    isAddingToCart,
    // Команды для View
    incrementQuantity,
    decrementQuantity,
    addToCart,
  };
}

// View — только рендеринг, никакой логики
function ProductPage({ productId }: { productId: string }) {
  // View использует ViewModel через хук
  const vm = useProductViewModel(productId);

  if (vm.isLoading) return <Skeleton />;
  if (vm.error) return <ErrorMessage error={vm.error} />;
  if (!vm.product) return null;

  return (
    <div>
      <h1>{vm.product.name}</h1>
      <p className={vm.hasDiscount ? 'price--discounted' : 'price'}>
        {vm.formattedPrice}
      </p>
      <div className="quantity">
        <button onClick={vm.decrementQuantity} disabled={!vm.canAddLess}>-</button>
        <span>{vm.quantity}</span>
        <button onClick={vm.incrementQuantity} disabled={!vm.canAddMore}>+</button>
      </div>
      <button onClick={vm.addToCart} disabled={vm.isAddingToCart}>
        {vm.isAddingToCart ? 'Добавляем...' : 'В корзину'}
      </button>
    </div>
  );
}

Частые ошибки

  • Логика в View — если компонент содержит форматирование, вычисления, условную логику — это нарушение MVVM; переносите в хук/ViewModel.
  • ViewModel знает о DOM — ViewModel должна работать с чистыми данными; DOM-операции (document.querySelector) в ней недопустимы.
  • Жирный ViewModel — ViewModel не должен содержать бизнес-правила (скидки, валидацию бизнеса); это задача Model/Service.

Связанные темы

Ресурсы