MVVM
Model · View · ViewModel
L'architecture des interfaces réactives modernes — Vue, React, Angular. Le ViewModel est le pont intelligent entre les données et l'interface, éliminant le besoin de manipuler le DOM manuellement.
C'est quoi MVVM ?
MVVM remplace le Controller de MVC par un ViewModel — une couche intermédiaire qui expose des données réactives. Quand les données changent, l'interface se met à jour automatiquement, sans code manuel.
Le Model = le moteur (données réelles : vitesse, carburant).
Le ViewModel = le boîtier électronique (transforme les signaux bruts en valeurs lisibles).
La View = le tableau de bord (affiche les jauges — se met à jour seul quand les valeurs changent).
En MVC, le Controller dit manuellement à la View quoi afficher. En MVVM, la View observe le ViewModel — dès qu'une valeur change, l'UI se met à jour. Plus besoin d'écrire
document.getElementById(...).textContent = ....
Data Binding — synchronisation automatique
MVVM vs MVC — la différence concrète
❌ MVC — mise à jour manuelle du DOM
// Controller — doit mettre à jour le DOM manuellement
let compteur = 0;
function incrementer() {
compteur++;
// Mettre à jour MANUELLEMENT le DOM
document.getElementById('affichage').textContent = compteur;
document.getElementById('double').textContent = compteur * 2;
document.getElementById('btn').disabled = compteur >= 10;
// ... oublier une mise à jour = bug difficile à trouver
}
<!-- HTML -->
<span id="affichage">0</span>
<span id="double">0</span>
<button id="btn" onclick="incrementer()">+1</button>
✅ MVVM — mise à jour automatique (Vue.js)
// ViewModel — exposer des données réactives
// <script setup>
import { ref, computed } from 'vue';
const compteur = ref(0); // donnée réactive
const double = computed(() => compteur.value * 2); // dérivée auto
const maxAtteint = computed(() => compteur.value >= 10);
function incrementer() {
compteur.value++;
// C'est tout ! La View se met à jour seule.
}
<!-- View — template Vue -->
<span>{{ compteur }}</span>
<span>{{ double }}</span>
<button @click="incrementer" :disabled="maxAtteint">+1</button>
En Vue, ref() crée une valeur réactive. computed() crée une valeur dérivée qui se recalcule automatiquement. Le template se lie à ces valeurs — quand elles changent, le DOM se met à jour.
Le data binding — types et syntaxe
Vue.js
<!-- 1. Interpolation — ViewModel → View (one-way) -->
<p>{{ message }}</p>
<p>{{ prix.toFixed(2) }} €</p>
<!-- 2. Binding d'attribut — : est un raccourci de v-bind -->
<img :src="urlImage" :alt="titre">
<button :disabled="chargement">Envoyer</button>
<div :class="{ actif: estActif, erreur: aErreur }"></div>
<!-- 3. Événements — @ est un raccourci de v-on -->
<button @click="sauvegarder">Sauver</button>
<input @input="rechercherEnTempsReel">
<form @submit.prevent="soumettre">
<!-- 4. Two-way binding — v-model (View ↔ ViewModel) -->
<input v-model="nom"> <!-- saisie → ViewModel auto -->
<textarea v-model="bio"></textarea>
<select v-model="categorie">...</select>
<input type="checkbox" v-model="accepte">
<!-- 5. Directives de structure -->
<p v-if="estConnecte">Bienvenue !</p>
<p v-else>Connectez-vous</p>
<li v-for="article in articles" :key="article.id">
{{ article.titre }}
</li>
React (équivalent)
import { useState, useMemo } from 'react';
function Formulaire() {
// ViewModel = state + handlers du composant
const [nom, setNom] = useState('');
const [articles, setArticles] = useState([]);
const [chargement, setChargement] = useState(false);
// computed → useMemo en React
const nomMaj = useMemo(() => nom.toUpperCase(), [nom]);
return (
{/* View = JSX */}
<div>
{/* two-way binding manuel en React */}
<input value={nom} onChange={e => setNom(e.target.value)} />
<p>{nomMaj}</p>
<button disabled={chargement}>Envoyer</button>
<ul>
{articles.map(a => (
<li key={a.id}>{a.titre}</li>
))}
</ul>
</div>
);
}
React n'est pas MVVM au sens strict — il n'y a pas de two-way binding automatique (v-model). Le binding est explicite : value={state} + onChange={handler}. Mais le principe est identique : le composant est le ViewModel.
MVVM complet avec Vue.js
<!-- ArticleList.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { api } from '../services/api'; // ← Model (API)
// --- ViewModel : données réactives ---
const articles = ref([]);
const recherche = ref('');
const chargement = ref(true);
const erreur = ref(null);
// Données dérivées — recalculées automatiquement
const articlesFiltres = computed(() =>
articles.value.filter(a =>
a.titre.toLowerCase().includes(recherche.value.toLowerCase())
)
);
const nbResultats = computed(() => articlesFiltres.value.length);
// --- Actions ---
async function chargerArticles() {
try {
articles.value = await api.get('/articles'); // ← Model
} catch (e) { erreur.value = e.message; }
finally { chargement.value = false; }
}
async function supprimer(id) {
await api.delete(`/articles/${id}`);
articles.value = articles.value.filter(a => a.id !== id);
}
onMounted(chargerArticles); // charger au montage
</script>
<template>
<!-- La View observe le ViewModel -- aucun code JS ici -->
<div v-if="chargement">Chargement...</div>
<div v-else-if="erreur">Erreur : {{ erreur }}</div>
<div v-else>
<!-- Two-way binding : recherche ↔ input -->
<input v-model="recherche" placeholder="Filtrer...">
<p>{{ nbResultats }} résultats</p>
<!-- Liste réactive -->
<article
v-for="article in articlesFiltres"
:key="article.id"
>
<h3>{{ article.titre }}</h3>
<button @click="supprimer(article.id)">
Supprimer
</button>
</article>
</div>
</template>
La structure d'un .vue reflète exactement MVVM : <script setup> = ViewModel, <template> = View, les appels API = Model. Chaque fichier est un composant autonome et testable.
MVVM avec React — hooks comme ViewModel
// hooks/useArticles.js — ViewModel isolé
import { useState, useEffect, useMemo } from 'react';
export function useArticles() {
// État réactif (comme ref() de Vue)
const [articles, setArticles] = useState([]);
const [recherche, setRecherche] = useState('');
const [chargement, setChargement] = useState(true);
// computed → useMemo
const articlesFiltres = useMemo(() =>
articles.filter(a =>
a.titre.toLowerCase().includes(recherche.toLowerCase())
),
[articles, recherche]
);
// onMounted → useEffect
useEffect(() => {
fetch('/api/articles')
.then(r => r.json())
.then(setArticles)
.finally(() => setChargement(false));
}, []);
const supprimer = (id) => {
fetch(`/api/articles/${id}`, { method: 'DELETE' });
setArticles(prev => prev.filter(a => a.id !== id));
};
// Exposer au composant View
return { articles: articlesFiltres, recherche, setRecherche,
chargement, supprimer };
}
// components/ArticleList.jsx — View pure
import { useArticles } from '../hooks/useArticles';
export function ArticleList() {
// Le composant est la View — il consomme le ViewModel
const { articles, recherche, setRecherche,
chargement, supprimer } = useArticles();
if (chargement) return <p>Chargement...</p>;
return (
<div>
<input
value={recherche}
onChange={e => setRecherche(e.target.value)}
placeholder="Filtrer..."
/>
{articles.map(a => (
<article key={a.id}>
<h3>{a.titre}</h3>
<button onClick={() => supprimer(a.id)}>
Supprimer
</button>
</article>
))}
</div>
);
}
Extraire la logique dans un custom hook est la bonne pratique React pour MVVM : le hook est le ViewModel testable indépendamment, le composant est la View qui ne fait qu'afficher.
MVVM côté Python — contexte
MVVM est un pattern front-end par nature. Côté Python, on le rencontre dans les applications desktop (PyQt, Tkinter) ou en combinaison avec un front Vue/React qui consomme une API Flask/FastAPI.
from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty
# Model — données
class CompteurModel:
def __init__(self): self.valeur = 0
def incrementer(self): self.valeur += 1
# ViewModel — expose les données + notifie la View
class CompteurViewModel(QObject):
valeurChangee = pyqtSignal(int) # signal réactif
def __init__(self):
super().__init__()
self._model = CompteurModel()
@pyqtProperty(int, notify=valeurChangee)
def valeur(self): return self._model.valeur
def incrementer(self):
self._model.incrementer()
self.valeurChangee.emit(self._model.valeur) # notif View
# FastAPI (Python) = Model + API
# Vue.js / React = ViewModel + View
# api/main.py — FastAPI expose les données
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Article(BaseModel):
id: int
titre: str
contenu: str
articles_db = [
Article(id=1, titre="Premier", contenu="...")
]
@app.get("/articles")
async def lister():
return articles_db # ← Model → JSON
------------------------------------------
# Vue.js consomme l'API (ViewModel côté front)
# const articles = ref([])
# onMounted(() => fetch('/articles')
# .then(r => r.json())
# .then(d => articles.value = d))
La combinaison FastAPI/Flask (back) + Vue/React (front) est un MVVM moderne : Python gère le Model (BDD, logique métier), JavaScript gère le ViewModel et la View. C'est la stack la plus fréquente en 2024.
État global — Pinia (Vue) & Redux (React)
// stores/articlesStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useArticlesStore = defineStore('articles', () => {
// State (équivaut à ref() dans un composant)
const articles = ref([]);
const chargement = ref(false);
// Getters (computed)
const publies = computed(() => articles.value.filter(a => a.publie));
// Actions
async function charger() {
chargement.value = true;
articles.value = await fetch('/api/articles').then(r => r.json());
chargement.value = false;
}
return { articles, chargement, publies, charger };
});
// Dans n'importe quel composant :
const store = useArticlesStore();
store.charger(); // tous les composants voient la même donnée
Quand un ViewModel par composant ne suffit plus — par exemple, partager l'utilisateur connecté entre 10 composants — on utilise un store global (Pinia pour Vue, Redux/Zustand pour React). C'est un ViewModel partagé.
import { create } from 'zustand';
const useAuthStore = create(set => ({
user: null,
connecte: false,
connecter: (userData) => set({
user: userData, connecte: true
}),
deconnecter: () => set({ user: null, connecte: false }),
}));
// Dans n'importe quel composant :
const { user, connecte, deconnecter } = useAuthStore();
Store = ViewModel global. Les composants s'abonnent au store — quand les données changent, seuls les composants qui utilisent ces données se re-rendent.
Avantages & limites
Avantages
| Réactivité automatique | Plus de manipulations manuelles du DOM |
| UI déclarative | On décrit ce qu'on veut afficher, pas comment le mettre à jour |
| ViewModel testable | Tester la logique sans navigateur ni DOM |
| Two-way binding | Formulaires synchronisés sans event listeners manuels |
| Composants réutilisables | Chaque composant = ViewModel + View autonomes |
Limites
| Courbe d'apprentissage | ref/reactive/computed, effets de bord |
| Overhead | Réactivité = système de tracking coûteux pour de petites apps |
| Debugging complexe | Mise à jour automatique = difficile à tracer sans devtools |
| SEO | SPA = contenu généré côté client → invisible pour les bots (sauf SSR) |
Cheat sheet MVVM
Vue 3 ↔ Concepts MVVM
ref() | Donnée réactive (ViewModel) |
computed() | Donnée dérivée automatique |
reactive() | Objet réactif entier |
watch() | Réagir aux changements |
v-model | Two-way binding |
v-bind / : | One-way VM → View |
v-on / @ | Event View → VM |
onMounted() | Charger les données |
React ↔ Concepts MVVM
useState() | Donnée réactive |
useMemo() | Donnée dérivée |
useEffect() | Effets de bord / chargement |
useReducer() | État complexe |
value + onChange | Two-way binding explicite |
custom hook | ViewModel réutilisable |
Zustand / Redux | ViewModel global |
MVC vs MVVM — résumé
| MVC Controller | Choisit la View, injecte données |
| MVVM ViewModel | Expose données réactives |
| MVC View | Template passif (Jinja, EJS) |
| MVVM View | Template actif, se bind au VM |
| MVC flux | Controller → View (one-way) |
| MVVM flux | VM ↔ View (two-way possible) |
Quand utiliser MVVM ?
| SPA (Single Page App) | ✓ idéal |
| Interface très interactive | ✓ idéal |
| Formulaires complexes | ✓ idéal |
| Site vitrine simple | ✗ overkill |
| API REST back-end | ✗ MVC suffit |
| App desktop Python | ✓ PyQt/Kivy |