Une todo-list en React : le même projet que la version vanilla, comparé
J'ai déjà publié une todo-list en TypeScript sans framework : 134 lignes, un tableau qui fait foi, une fonction afficher() qui redessine toute la liste à chaque changement. La question qui revient ensuite est toujours la même : et en React, qu'est-ce que ça change vraiment ? Plutôt que d'y répondre par des généralités, j'ai refait exactement le même projet en React, avec les mêmes fonctionnalités, le même CSS et la même sauvegarde, puis j'ai fait passer les deux versions dans le même navigateur, piloté par un script.
Le résultat tient en quatre constats mesurés : ce que chaque version fait réellement à la page quand on coche une case, ce qu'il advient du focus clavier, le poids de ce qu'on envoie au navigateur, et deux erreurs typiques de React qui ne plantent rien : l'une retarde l'affichage d'un changement, l'autre efface la sauvegarde, en production comme en développement ou seulement en développement selon l'ordre des effets. Tout le code de cette page a été exécuté pendant la rédaction, le 2 novembre 2026 ; les versions sont listées en fin d'article.
Créer le projet avec Vite, et ce que l'on installe vraiment
La version vanilla demandait un dossier vide et une seule dépendance, TypeScript. Pour React, l'outil de démarrage courant est Vite, qui génère un projet prêt à lancer avec son gabarit react-ts (React et TypeScript) :
npm create vite@latest todo-react -- --template react-ts --no-interactive
cd todo-react
npm install
npm run devChez moi, cette commande a utilisé create-vite 9.3.3 et posé dans package.json neuf dépendances directes : react et react-dom 19.3.1, puis, pour le développement, Vite 8.3.5, son extension @vitejs/plugin-react 6.1.2, TypeScript 7.0.2, l'analyseur oxlint et trois paquets de types. Après npm install, npm ls --all --parseable compte 28 paquets installés sous Windows 11, contre 2 pour la version vanilla (TypeScript et son binaire pour Windows). La somme des fichiers de node_modules passe de 30,9 à 96,5 millions d'octets juste après npm install. Rien de tout cela n'est envoyé au navigateur, mais c'est ce que vous mettez à jour et ce que vous devez comprendre quand une commande échoue.
Une surprise en lisant le tsconfig.app.json généré : il ne contient pas la ligne "strict": true, que l'article vanilla présentait comme indispensable. Elle n'y est plus nécessaire, car strict est vrai par défaut depuis TypeScript 6.0. Je l'ai vérifié en glissant dans le projet le même extrait fautif que dans la version vanilla, un querySelector dont on lit .value : npx tsc -p tsconfig.app.json répond error TS18047: 'champ' is possibly 'null'. puis error TS2339: Property 'value' does not exist on type 'Element'., les deux mêmes erreurs.
J'ai supprimé les fichiers de démonstration du gabarit (logos, App.css, index.css) et repris dans un src/style.css les mêmes sept règles CSS que la version vanilla, à l'octet près. La page HTML se réduit à un conteneur vide, et src/main.tsx y monte le composant :
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ma todo-list</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./style.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);Retenez le <StrictMode> posé par le gabarit : il active des vérifications supplémentaires en développement, et c'est lui qui fera apparaître la seconde erreur de cet article.
Le composant complet, et ce qui a disparu par rapport à la version vanilla
Voici src/App.tsx en entier : 105 lignes, contre 134 pour app.ts. L'interface Tache et la fonction de contrôle estUneTache sont recopiées sans changement ; charger() renvoie maintenant le tableau au lieu de modifier une variable globale.
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
// Une tâche de la liste : le même contrat que la version vanilla.
interface Tache {
id: number;
texte: string;
faite: boolean;
}
function estUneTache(valeur: unknown): valeur is Tache {
return (
typeof valeur === "object" &&
valeur !== null &&
typeof (valeur as Tache).id === "number" &&
typeof (valeur as Tache).texte === "string" &&
typeof (valeur as Tache).faite === "boolean"
);
}
// Relit la sauvegarde ; renvoie une liste vide si elle est absente ou abîmée.
function charger(): Tache[] {
const brut = localStorage.getItem("taches");
if (brut === null) {
return [];
}
try {
const donnees: unknown = JSON.parse(brut);
if (Array.isArray(donnees) && donnees.every(estUneTache)) {
return donnees;
}
} catch {
// JSON illisible : on repart d'une liste vide.
}
return [];
}
export default function App() {
// charger est passée sans parenthèses : React ne l'appelle qu'au premier rendu.
const [taches, setTaches] = useState<Tache[]>(charger);
const [texte, setTexte] = useState("");
const champ = useRef<HTMLInputElement>(null);
// Sauvegarde après chaque rendu où le tableau a changé.
useEffect(() => {
localStorage.setItem("taches", JSON.stringify(taches));
}, [taches]);
function ajouter(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
const propre = texte.trim();
if (propre === "") {
return;
}
const prochainId = taches.reduce((max, t) => Math.max(max, t.id), 0) + 1;
setTaches([...taches, { id: prochainId, texte: propre, faite: false }]);
setTexte("");
champ.current?.focus();
}
function basculer(id: number) {
setTaches(taches.map((t) => (t.id === id ? { ...t, faite: !t.faite } : t)));
}
function supprimer(id: number) {
setTaches(taches.filter((t) => t.id !== id));
}
const restantes = taches.filter((t) => !t.faite).length;
return (
<>
<h1>Ma todo-list</h1>
<form onSubmit={ajouter}>
<input
ref={champ}
type="text"
value={texte}
onChange={(e) => setTexte(e.target.value)}
placeholder="Quelque chose à faire…"
required
/>
<button type="submit">Ajouter</button>
</form>
<ul>
{taches.map((tache) => (
<li key={tache.id} className={tache.faite ? "faite" : undefined}>
<input
type="checkbox"
checked={tache.faite}
onChange={() => basculer(tache.id)}
/>
<span>{tache.texte}</span>
<button type="button" onClick={() => supprimer(tache.id)}>
Supprimer
</button>
</li>
))}
</ul>
<p>
{restantes} tâche(s) à faire sur {taches.length}
</p>
</>
);
}Trois choses ont disparu. La fonction recuperer() et ses quatre appels : on ne va plus chercher les éléments, on les décrit dans le JSX. La fonction afficher() et ses createElement en série : le bloc return décrit ce que la page doit montrer pour un tableau donné, et React se charge de la mettre en conformité. Les addEventListener, enfin, remplacés par des attributs onChange et onClick. Si vous voulez revoir ce que React fait à votre place, l'article sur la manipulation du DOM détaille ces appels un par un.
En échange, trois notions nouvelles apparaissent. useState déclare un état : une valeur que React garde entre deux rendus, et qu'on ne change qu'en appelant la fonction qui l'accompagne (setTaches), ce qui provoque un nouveau rendu. Le champ texte devient « contrôlé » : sa valeur vient de l'état texte et chaque frappe passe par setTexte. Et chaque <li> porte une key, l'identifiant de la tâche, qui permet à React de reconnaître une ligne d'un rendu à l'autre. Remarquez aussi que basculer et supprimer ne modifient jamais le tableau existant : map et filter en fabriquent un nouveau. Ce n'est pas une question de style, la section sur les erreurs montre ce qui arrive sinon.
Côté identifiants, j'ai abandonné le compteur prochainId de la version vanilla au profit du plus grand id existant plus un, recalculé à chaque ajout : il n'y a plus rien à reprendre au chargement. Au banc, supprimer la première tâche puis en ajouter une donne les ids [2, 3, 4] dans les deux versions. Différence de comportement à connaître : si vous supprimez la tâche qui porte le plus grand id, la suivante le récupère, ce qui ne pose pas de problème ici puisque l'ancienne n'existe plus.
Cocher une case : trois lignes recréées contre une classe ajoutée
Le banc ajoute les trois mêmes tâches dans chaque version (« acheter du pain », « réviser Flexbox », « publier l'article »), puis pose un MutationObserver sur la liste, l'outil du navigateur qui consigne chaque modification du DOM, avant de cocher la deuxième tâche à la souris. Pour l'utilisateur, le résultat est identique : la ligne passe en classe faite, son texte est barré (text-decoration-line vaut line-through) et le compteur affiche 2 tâche(s) à faire sur 3. Sous le capot, rien à voir :
| Relevé après le clic | Vanilla | React |
|---|---|---|
Lignes <li> retirées de la liste | 3 | 0 |
Lignes <li> ajoutées | 3 | 0 |
| Lignes qui sont encore les mêmes objets qu'avant le clic | 0 sur 3 | 3 sur 3 |
| Modifications consignées par l'observateur | 4 | 13, toutes des attributs |
La version vanilla fait exactement ce que son code dit : liste.innerHTML = "" retire les trois lignes d'un coup (une modification), puis trois append en ajoutent trois neuves (trois modifications). React, lui, garde les trois lignes. Parmi ses 13 modifications, une seule change réellement la page : l'attribut class de la deuxième ligne, qui passe d'absent à "faite". Les douze autres sont des réécritures des attributs name et type des cases à cocher, qui reviennent toutes à leur valeur de départ : React repasse sur les cases, mais n'en modifie réellement aucune.
Pour trois tâches, cette différence ne se voit pas à l'œil, et je ne vous dirai pas que la version vanilla est « lente » : je n'ai rien mesuré de tel, et redessiner trois lignes ne coûte presque rien. La vraie conséquence est ailleurs, et elle concerne les personnes qui naviguent au clavier.
Le focus clavier, la différence qu'on ne voit pas à la souris
Deuxième scénario du banc : placer le focus sur la case de la troisième tâche, comme le ferait quelqu'un qui avance avec la touche Tab, puis appuyer sur Espace pour la cocher. Le relevé de document.activeElement est fait deux images après la touche, pour laisser au navigateur le temps de mettre la page à jour. Dans les deux versions, la tâche est bien cochée et le compteur passe à 1 tâche(s) à faire sur 3. Mais :
- version vanilla : après Espace, le focus est sur
body, c'est-à-dire nulle part. La case qui l'avait a été détruite parafficher()et remplacée par une case neuve qui ne l'a pas. Dans Chrome, la touche Tab suivante amène le focus sur la case de la première ligne ; - version React : après Espace, le focus est toujours sur la case de la troisième ligne, puisque c'est le même élément. La touche Tab suivante amène sur le bouton « Supprimer » de cette même ligne, ce qu'on attend.
Pour une personne qui navigue au clavier seul, la version vanilla l'oblige donc, dans Chrome, à repartir du haut de la liste après chaque case cochée. Ce défaut n'est pas propre au JavaScript sans framework, il est propre à la stratégie « je redessine tout », que j'avais choisie dans l'article vanilla pour sa simplicité. On peut le corriger sans React, en mettant à jour la seule ligne concernée ou en replaçant le focus à la main après afficher(), mais il faut y penser. React le règle sans qu'on ait rien demandé, parce qu'il compare le nouveau rendu à l'ancien et met à jour les éléments existants (la key sert à reconnaître chaque ligne quand la liste change de forme).
Modifier l'état au lieu de le remplacer : la case qui ne répond pas
Dans la version vanilla, basculer() modifie directement l'objet trouvé, tache.faite = !tache.faite, puis appelle afficher(). Recopier cette logique telle quelle en React est le réflexe le plus naturel du monde :
function basculer(id: number) {
const tache = taches.find((t) => t.id === id);
if (tache) {
tache.faite = !tache.faite; // on modifie l'objet existant
setTaches(taches); // et on repasse le MÊME tableau
}
}Ce code compile sans aucune erreur. Au banc, sur le build de production comme en mode développement, le résultat est le même. Après le clic sur la case de la deuxième tâche, la case est décochée, la ligne n'a pas de classe, le compteur affiche toujours 3 tâche(s) à faire sur 3 et la sauvegarde contient "faite":false pour cette tâche. Le clic n'a rien fait de visible. Puis j'ajoute une quatrième tâche, et la deuxième apparaît soudain cochée et barrée, avec un compteur à 3 tâche(s) à faire sur 4 et "faite":true dans la sauvegarde. Le changement était bien dans l'objet ; il a simplement attendu le rendu suivant pour se montrer.
La documentation de useState explique pourquoi : si la nouvelle valeur passée à setTaches est identique à l'état actuel au sens d'Object.is, React saute le nouveau rendu. Un tableau modifié sur place reste le même tableau, donc identique. Dans la version vanilla, afficher() redessinait tout sans condition, ce qui rendait la mutation inoffensive. En React, la règle est de fabriquer une nouvelle valeur, comme le fait le map du composant complet. C'est un bug d'autant plus traître qu'il se manifeste de façon décalée : on cherche la cause dans l'ajout, alors qu'elle est dans le clic précédent.
Charger la sauvegarde dans un useEffect : la liste qui se vide
Pour la sauvegarde, une façon de faire que l'on croise souvent consiste à écrire deux effets : l'un qui charge au démarrage, l'autre qui sauvegarde à chaque changement, avec un état qui part d'un tableau vide. J'ai testé les deux ordres de déclaration possibles :
const [taches, setTaches] = useState<Tache[]>([]);
// Sauvegarde après chaque rendu où le tableau a changé.
useEffect(() => {
localStorage.setItem("taches", JSON.stringify(taches));
}, [taches]);
// Chargement au montage, APRÈS l'effet de sauvegarde.
useEffect(() => {
setTaches(charger());
}, []);Le banc pose une sauvegarde de trois tâches avant le chargement de la page, puis l'ouvre en espionnant chaque lecture et chaque écriture de la clé taches. Avec l'ordre ci-dessus, le journal du build de production est écriture [], lecture [], écriture [] : la sauvegarde s'exécute en premier avec l'état de départ, écrase les trois tâches par un tableau vide, et le chargement relit ce vide. Le compteur affiche 0 tâche(s) à faire sur 0, et les trois tâches sont perdues pour de bon, rechargement compris.
Avec le chargement déclaré avant la sauvegarde, c'est plus sournois. Dans le build de production, tout fonctionne : journal lecture 3 tâches, écriture [], écriture 3 tâches, compteur à 2 tâche(s) à faire sur 3. L'écriture du tableau vide a bien lieu, mais le rendu suivant réécrit aussitôt les trois tâches. En mode développement, en revanche, les trois tâches disparaissent : journal lecture 3 tâches, écriture [], lecture [], écriture [], écriture [], compteur à 0 tâche(s) à faire sur 0.
L'explication est dans la documentation de StrictMode : en développement, React exécute un cycle supplémentaire de mise en place et de nettoyage pour chaque effet, et ces vérifications ne tournent jamais en production. Au second passage, l'effet de chargement relit donc une sauvegarde que l'effet de sauvegarde vient de vider, et cette mise à jour vide, arrivée en dernier, l'emporte. Le défaut existait déjà en production : une écriture du tableau vide a lieu à chaque démarrage, et la version de production ne s'en remet que parce que l'écriture suivante arrive tout de suite. StrictMode ne fabrique pas le bug, il le rend visible ; encore faut-il comprendre que la perte de données en développement est le symptôme, et non un caprice de l'outil.
La correction est celle du composant complet : supprimer l'effet de chargement et passer charger en initialiseur de useState, sans parenthèses. React ne l'appelle qu'à l'initialisation, si bien que le premier rendu a déjà les bonnes tâches et que la première sauvegarde écrit ce qui a été chargé. En mode développement, la documentation précise que React appelle même cet initialiseur deux fois pour repérer les fonctions impures, sans conséquence ici puisque charger() ne fait que lire. J'ai rejoué le scénario complet du banc sur la version corrigée en mode développement et sur le build : résultats identiques, rechargement compris (1 tâche(s) à faire sur 3 avec les deux dernières tâches cochées), sauvegardes abîmées comprises ({"pas":"un tableau"} et du texte qui n'est pas du JSON donnent tous deux 0 tâche(s) à faire sur 0, sans plantage), et aucun avertissement ni erreur dans la console (React n'y affiche en développement que son message d'information qui suggère d'installer les React DevTools). La validation par unknown et prédicat de type, expliquée dans l'article sur unknown et any, reste aussi utile en React qu'en vanilla.
Le prix à payer : ce que le navigateur télécharge
Dernier relevé, sans navigateur cette fois : la taille du JavaScript envoyé au visiteur. Côté vanilla, le fichier compilé par tsc ; côté React, le fichier produit par npm run build (Vite 8.3.5). Les tailles compressées sont calculées avec la même méthode pour les deux (gzip niveau 9 de Node.js) ; Vite affiche ses propres chiffres à la fin du build, légèrement différents (« 69.19 kB » pour le fichier React).
| Fichier JavaScript | Taille brute | Compressé (gzip) |
|---|---|---|
Vanilla : dist/app.js (non minifié, commentaires compris) | 3 423 octets | 1 322 octets |
| React : la todo-list complète | 220 949 octets | 68 308 octets |
React : un composant qui ne renvoie que null | 219 517 octets | 67 700 octets |
La dernière ligne isole le coût de React lui-même : un composant vide, construit avec la même configuration, pèse déjà 219 517 octets. Notre todo-list n'ajoute par-dessus que 1 432 octets minifiés, soit 608 octets compressés, moins que la version vanilla entière (non minifiée). Autrement dit, React ne rend pas votre code plus lourd, il ajoute un socle fixe de 67 700 octets compressés que le visiteur télécharge avant de voir la moindre tâche. React et React DOM ne grossissent pas avec le nombre de vos composants ; ce sont les bibliothèques que l'on ajoute ensuite qui s'y empilent. C'est sur les gros projets que ce socle s'amortit.
Alors, vanilla ou React pour ce projet ?
Pour une todo-list isolée, mon avis est net : la version vanilla suffit, et c'est elle qu'il faut avoir écrite d'abord. Elle télécharge plus de 50 fois moins de JavaScript compressé, n'a qu'une dépendance, et surtout elle vous oblige à comprendre ce que React fera ensuite à votre place : créer les éléments, brancher les événements, garder la page synchronisée avec les données. Sans ce passage, le JSX reste une boîte noire.
Mais le banc montre aussi ce que React apporte réellement, au-delà de l'effet de mode : il réutilise les éléments existants, ce qui a réglé sans effort un défaut d'accessibilité que ma version vanilla avait, et il impose une discipline (ne jamais modifier l'état, ne pas charger dans un effet) qui devient précieuse quand l'application grossit. Le revers de cette discipline, c'est que ses violations ne plantent pas : elles produisent une case qui ne répond pas ou une liste vidée, en développement seulement ou aussi en production selon l'ordre des effets, deux symptômes qu'aucun compilateur n'a signalés. Si vous apprenez React aujourd'hui, retenez surtout ces deux erreurs ; ce sont deux erreurs probables au début, parce qu'elles reprennent les réflexes du JavaScript sans framework.
Comment ont été faites les mesures
Environnement : Windows 11, Node.js 22.23.3, npm, create-vite 9.3.3, React et React DOM 19.3.1, Vite 8.3.5, @vitejs/plugin-react 6.1.2, TypeScript 7.0.2 (les deux projets), Playwright 1.64.1 pilotant Chrome Headless Shell 156.0.8078.4. La version vanilla est le fichier complet publié dans l'article d'origine, recompilé à l'identique (134 lignes de TypeScript, 108 lignes de JavaScript plus le commentaire de source map). Les deux versions sont servies en local, les variantes fautives React sont mesurées sur leur build et sur le serveur de développement de Vite. Chaque relevé d'état est fait deux images (requestAnimationFrame) après l'action, et sur six exécutions du scénario complet, tous les relevés ont été identiques (seul l'ordre de deux messages de console du serveur de développement a varié une fois).
Sources consultées le 2 novembre 2026 :
- la référence de useState : initialiseur appelé seulement à l'initialisation, et deux fois en développement sous StrictMode ; rendu sauté quand la nouvelle valeur est identique au sens d'
Object.is; exemple de l'objet muté ignoré ; - la référence de StrictMode : cycle supplémentaire de mise en place et de nettoyage pour chaque effet, vérifications limitées au développement ;
- l'annonce de TypeScript 6.0, qui passe
strictà vrai par défaut.