Design Pattern
Patterns front-end

Presenter / ViewModel

Séparer la logique de présentation du rendu

Problème

Les composants React finissent souvent avec deux responsabilités mélangées : transformer les données brutes pour l'affichage, et rendre le JSX. Quand la logique de formatage grossit, le composant devient difficile à tester et à maintenir.

Presenter

Le Presenter prend les données brutes et produit des données prêtes à l'affichage. Le composant ne fait que du rendu.

// Données brutes venant du store ou de l'API
interface Order {
  id: string;
  createdAt: string; // ISO 8601
  totalCents: number;
  status: 'pending' | 'shipped' | 'delivered' | 'cancelled';
  items: OrderItem[];
}

// Données formatées pour l'affichage
interface OrderViewModel {
  id: string;
  date: string;          // "12 mai 2026"
  total: string;         // "42,00 €"
  statusLabel: string;   // "En cours de livraison"
  statusColor: string;   // "text-yellow-600"
  itemCount: string;     // "3 articles"
}

function presentOrder(order: Order): OrderViewModel {
  const STATUS_MAP = {
    pending:   { label: 'En attente',             color: 'text-gray-500' },
    shipped:   { label: 'En cours de livraison',  color: 'text-yellow-600' },
    delivered: { label: 'Livré',                  color: 'text-green-600' },
    cancelled: { label: 'Annulé',                 color: 'text-red-500' },
  };

  return {
    id: order.id,
    date: new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(order.createdAt)),
    total: new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(order.totalCents / 100),
    statusLabel: STATUS_MAP[order.status].label,
    statusColor: STATUS_MAP[order.status].color,
    itemCount: `${order.items.length} article${order.items.length > 1 ? 's' : ''}`,
  };
}

// Le composant ne contient plus aucune logique de formatage
function OrderCard({ order }: { order: Order }) {
  const vm = presentOrder(order);

  return (
    <div>
      <p>{vm.date} · {vm.itemCount}</p>
      <p className={vm.statusColor}>{vm.statusLabel}</p>
      <p>{vm.total}</p>
    </div>
  );
}

Presenter avec hook

En React, il est courant d'encapsuler le presenter dans un hook :

function useOrderViewModel(order: Order): OrderViewModel {
  return useMemo(() => presentOrder(order), [order]);
}

function OrderCard({ order }: { order: Order }) {
  const vm = useOrderViewModel(order);
  return (/* JSX */)
}

Le useMemo évite de recalculer le ViewModel à chaque rendu si order n'a pas changé.

Testabilité

Le vrai avantage du Presenter : la logique de formatage est une fonction pure, testable sans React.

describe('presentOrder', () => {
  it('formate le total en euros', () => {
    const vm = presentOrder({ ...baseOrder, totalCents: 4200 });
    expect(vm.total).toBe('42,00 €');
  });

  it('affiche le bon label pour shipped', () => {
    const vm = presentOrder({ ...baseOrder, status: 'shipped' });
    expect(vm.statusLabel).toBe('En cours de livraison');
    expect(vm.statusColor).toBe('text-yellow-600');
  });
});

Tester la même logique dans un composant nécessiterait un render, des queries DOM, et un environnement jsdom.