📍 Le guide du parcours : JavaScript moderne : le guide complet
Cinquième tutoriel de la série JavaScript moderne. Il branche l’interface visuelle sur la logique et les données des tutoriels précédents.
Introduction
Jusqu’à présent, CarnetTâches ne vivait que dans la console. Il est temps de l’afficher : une vraie liste à l’écran, un formulaire pour ajouter une tâche, un bouton pour la cocher, et des données venues d’un serveur via fetch. Pas de framework, pas de bibliothèque — juste le DOM et les API du navigateur, celles sur lesquelles React et Vue eux-mêmes reposent. À la fin de ce tutoriel, vous saurez sélectionner et modifier des éléments de la page, réagir aux clics et aux saisies, et consommer une API HTTP proprement, jusqu’à annuler une requête devenue inutile.
🎯 Ce que vous allez apprendre
- Sélectionner des éléments avec
querySelectoret les modifier sans danger. - Écouter des événements avec
addEventListeneret utiliser la délégation d’événements. - Construire dynamiquement une liste dans le DOM à partir de données.
- Consommer une API HTTP avec
fetch, en vérifiant le statut de la réponse. - Annuler une requête en cours avec
AbortController.
🛠️ Ce que vous allez construire
Vous allez assembler l’interface complète de CarnetTâches : une liste qui se remplit depuis une API publique de test, un formulaire d’ajout, des cases à cocher qui basculent l’état « faite », et un champ de recherche qui interroge le serveur avec annulation des requêtes obsolètes. Le tout dans une seule page HTML servie localement.
Prérequis
- Un navigateur récent, sa console (F12), et un serveur local pour servir la page (les modules l’exigent).
- Avoir compris l’asynchrone et les modules. Test express : si vous savez ce que renvoie une fonction
asyncet écrire unimport, vous êtes prêt ; sinon, voyez Promesses et async/await. - ⏱️ Temps estimé : ~50 minutes.
Étape 1 — Sélectionner et lire le DOM
Le DOM (Document Object Model) est la représentation arborescente de la page que JavaScript peut lire et modifier. Pour agir sur un élément, il faut d’abord le sélectionner. document.querySelector prend un sélecteur CSS et renvoie le premier élément correspondant ; querySelectorAll renvoie tous les correspondants. C’est l’unique paire de méthodes dont vous avez besoin au quotidien. Partons de cette page minimale.
<!-- index.html -->
<main>
<h1>CarnetTâches</h1>
<form id="ajout">
<input id="titre" placeholder="Nouvelle tâche" required>
<button type="submit">Ajouter</button>
</form>
<input id="recherche" placeholder="Rechercher...">
<ul id="liste"></ul>
</main>
<script type="module" src="ui.js"></script>
// ui.js
const liste = document.querySelector("#liste");
const formulaire = document.querySelector("#ajout");
const champTitre = document.querySelector("#titre");
const champRecherche = document.querySelector("#recherche");
console.log(liste); // <ul id="liste"></ul>
console.log(champTitre.value); // "" au départ
Un module est chargé en différé (comme s’il avait l’attribut defer), donc le DOM est prêt quand le code s’exécute : pas besoin d’attendre un événement de chargement. Si querySelector renvoie null, c’est que le sélecteur ne correspond à rien — vérifiez l’identifiant et qu’il existe bien dans le HTML.
✅ Point d’étape — Dans la console, tapez
document.querySelector("#liste"). Vous devez voir l’élémentul. S’il renvoienull, le script s’exécute avant que l’élément existe, ou l’identifiant est mal orthographié.
Étape 2 — Construire la liste à partir de données
Afficher des tâches, c’est transformer un tableau de données en éléments du DOM. La méthode la plus sûre construit chaque élément avec createElement et insère le texte avec textContent — jamais innerHTML avec des données venues de l’utilisateur, car cela ouvre la porte à l’injection de code. Écrivons la fonction de rendu.
function rendreListe(taches) {
liste.replaceChildren(); // vide la liste proprement
for (const t of taches) {
const li = document.createElement("li");
li.dataset.id = t.id; // on range l'id sur l'élément (data-id)
const case_ = document.createElement("input");
case_.type = "checkbox";
case_.checked = t.fait;
const span = document.createElement("span");
span.textContent = t.titre; // textContent : sûr, pas d'injection
if (t.fait) span.style.textDecoration = "line-through";
li.append(case_, span);
liste.append(li);
}
}
rendreListe([
{ id: 1, titre: "Réviser le DOM", fait: false },
{ id: 2, titre: "Boire de l'eau", fait: true }
]);
On vide d’abord la liste avec replaceChildren(), puis on recrée chaque ligne. dataset.id écrit un attribut data-id sur l’élément : on s’en servira pour retrouver quelle tâche a été cliquée. textContent insère du texte brut, à l’abri de toute interprétation HTML — la règle de sécurité numéro un quand on affiche des données. Le rendu complet à chaque changement est simple et largement suffisant pour une liste de tâches.
Étape 3 — Réagir aux événements et la délégation
Une interface vivante réagit aux actions de l’utilisateur. addEventListener attache une fonction à un événement (clic, saisie, soumission). Plutôt que de poser un écouteur sur chaque case à cocher — fragile dès que la liste change — on en pose un seul sur le conteneur, qui intercepte les événements remontant depuis les enfants. C’est la délégation d’événements, le bon réflexe pour les listes dynamiques.
let taches = [
{ id: 1, titre: "Réviser le DOM", fait: false },
{ id: 2, titre: "Boire de l'eau", fait: true }
];
// un seul écouteur sur la liste, pour toutes les cases présentes et futures
liste.addEventListener("change", (evenement) => {
if (evenement.target.type !== "checkbox") return;
const id = Number(evenement.target.closest("li").dataset.id);
const tache = taches.find(t => t.id === id);
tache.fait = evenement.target.checked; // met à jour l'état
rendreListe(taches); // re-rend l'interface
});
rendreListe(taches);
L’événement « remonte » de la case cliquée jusqu’à la liste, où notre écouteur l’attrape. On identifie la ligne concernée via closest("li") et son data-id, on modifie l’état, on re-rend. Un seul écouteur gère un nombre illimité de lignes, même celles ajoutées plus tard : c’est plus performant et plus simple que d’attacher et détacher des écouteurs au fil des changements.
✅ Point d’étape — Cochez puis décochez une case. Le titre correspondant doit se barrer et se débarrer. Si rien ne se passe, vérifiez dans la console que
evenement.target.typevaut bien"checkbox"au clic.
Étape 4 — Le formulaire d’ajout
Le formulaire mérite une attention particulière : par défaut, sa soumission recharge la page, ce qui anéantit l’état de l’application. On intercepte donc l’événement submit et on appelle preventDefault() pour reprendre la main. C’est l’une des premières choses à savoir en manipulation de formulaires.
let prochainId = 3;
formulaire.addEventListener("submit", (evenement) => {
evenement.preventDefault(); // empêche le rechargement de la page
const titre = champTitre.value.trim();
if (titre === "") return; // pas de tâche vide
taches.push({ id: prochainId++, titre, fait: false });
champTitre.value = ""; // vide le champ
champTitre.focus(); // prêt pour la saisie suivante
rendreListe(taches);
});
Sans preventDefault(), le navigateur rechargerait la page à chaque ajout et tout disparaîtrait. On lit la valeur saisie, on la nettoie avec trim(), on refuse les titres vides, puis on ajoute la tâche et on rafraîchit. Remettre le focus dans le champ après l’ajout est un petit détail qui rend la saisie en rafale beaucoup plus agréable.
Étape 5 — Consommer une API avec fetch
Place aux vraies données. L’API fetch envoie une requête HTTP et renvoie une promesse qui se résout en un objet Response. Point crucial souvent oublié : fetch ne rejette que sur une panne réseau, pas sur un statut d’erreur HTTP comme 404 ou 500. Il faut donc vérifier response.ok soi-même. On utilise ici une API publique de test pour charger des éléments.
async function chargerDepuisApi() {
try {
const reponse = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=5");
if (!reponse.ok) { // fetch NE rejette PAS sur 404/500
throw new Error("HTTP " + reponse.status);
}
const donnees = await reponse.json(); // parse le corps JSON (renvoie une promesse)
// on adapte le format de l'API à notre modèle de tâche
return donnees.map(d => ({ id: d.id, titre: d.title, fait: d.completed }));
} catch (erreur) {
console.error("Chargement impossible :", erreur.message);
return []; // repli : l'interface reste utilisable
}
}
taches = await chargerDepuisApi();
rendreListe(taches);
On vérifie reponse.ok (vrai pour un statut 200–299), on lit le corps avec response.json() — qui renvoie elle-même une promesse — puis on transforme la réponse pour qu’elle colle à notre modèle. Le try/catch capture aussi bien la panne réseau que l’erreur HTTP qu’on a levée nous-mêmes, et le repli sur une liste vide garde l’application fonctionnelle. C’est la même structure d’erreur que celle vue au tutoriel asynchrone, appliquée à de vraies données.
✅ Point d’étape — Au chargement, la liste doit afficher cinq éléments venus de l’API. Dans l’onglet « Réseau », vous verrez la requête et son statut 200. Si la liste est vide, regardez la console : le message d’erreur vous dira si c’est le réseau ou un statut HTTP.
Étape 6 — Recherche avec annulation
Dernière brique : un champ de recherche qui interroge le serveur. Le problème classique est la « course » entre requêtes : l’utilisateur tape vite, plusieurs requêtes partent, et une ancienne réponse peut arriver après une récente et écraser le bon résultat. AbortController résout cela en annulant la requête précédente avant d’en lancer une nouvelle. On combine cela avec le debounce du tutoriel sur les fonctions.
let controleur = null;
async function rechercher(terme) {
if (controleur) controleur.abort(); // annule la requête précédente
controleur = new AbortController();
try {
const reponse = await fetch(
"https://jsonplaceholder.typicode.com/todos?q=" + encodeURIComponent(terme),
{ signal: controleur.signal } // relie la requête au contrôleur
);
if (!reponse.ok) throw new Error("HTTP " + reponse.status);
const donnees = await reponse.json();
rendreListe(donnees.map(d => ({ id: d.id, titre: d.title, fait: d.completed })));
} catch (erreur) {
if (erreur.name === "AbortError") return; // annulation volontaire : on ignore
console.error("Recherche impossible :", erreur.message);
}
}
// debounce : on n'interroge le serveur que 400 ms après la dernière frappe
let minuterie;
champRecherche.addEventListener("input", (e) => {
clearTimeout(minuterie);
minuterie = setTimeout(() => rechercher(e.target.value), 400);
});
Chaque nouvelle recherche annule la précédente via controleur.abort(), ce qui fait rejeter sa promesse avec une erreur nommée AbortError — qu’on ignore volontairement, car c’est une annulation voulue, pas un bug. Le debounce évite de partir à chaque lettre. Résultat : sur une connexion lente, on économise des requêtes inutiles et on garantit que c’est toujours la dernière saisie qui s’affiche. Deux problèmes réels réglés en quelques lignes.
Une question légitime se pose ici : re-rendre toute la liste à chaque changement, comme on le fait depuis le début, n’est-ce pas du gaspillage ? Pour une liste de tâches de taille raisonnable — quelques dizaines, voire quelques centaines d’éléments — la réponse est non : le navigateur recompose ces éléments en une fraction de milliseconde, et le code reste d’une simplicité imbattable. C’est précisément le compromis que font les frameworks modernes, à ceci près qu’ils calculent les différences pour ne mettre à jour que ce qui a changé. Tant que vous n’observez pas de lenteur perceptible, le rendu complet est le bon choix : il est plus facile à raisonner et sans bug d’état résiduel. N’optimisez que lorsqu’une mesure réelle, dans l’onglet « Performances » des outils de développement, révèle un véritable goulot d’étranglement. Optimiser à l’aveugle ajoute de la complexité sans bénéfice — une erreur classique qu’il vaut mieux éviter dès le départ.
🐞 Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
| La page se recharge à l’ajout | Soumission de formulaire non interceptée | Appeler evenement.preventDefault() |
querySelector renvoie null |
Sélecteur erroné, ou script avant le DOM | Vérifier l’identifiant ; charger en module/defer |
fetch n’attrape pas une erreur 404 |
fetch ne rejette que sur panne réseau |
Tester response.ok et lever soi-même |
| Un ancien résultat de recherche écrase le récent | Course entre requêtes concurrentes | Annuler la précédente avec AbortController |
| Données utilisateur cassent la page | innerHTML interprète le HTML injecté |
Utiliser textContent pour le texte |
🌍 Réalités du terrain
Construire une interface sans framework a un avantage direct sur une bande passante limitée : rien à télécharger au-delà de votre propre code. Le DOM et fetch sont déjà dans le navigateur. Les deux techniques de cette leçon — le debounce et AbortController — réduisent concrètement le trafic réseau en supprimant les requêtes inutiles, ce qui compte quand chaque kilo-octet est facturé. Pensez aussi à toujours prévoir un état de repli (liste vide, message discret) en cas d’échec : une interface qui reste utilisable hors connexion vaut mieux qu’une page blanche au premier paquet perdu.
✅ Récapitulatif
Vous savez désormais sélectionner des éléments avec querySelector, les construire avec createElement et textContent (sans risque d’injection), et réagir aux actions de l’utilisateur via addEventListener et la délégation d’événements. Vous interceptez la soumission d’un formulaire, vous consommez une API avec fetch en vérifiant response.ok, et vous annulez les requêtes obsolètes avec AbortController. L’interface de CarnetTâches est complète et reliée à de vraies données.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
document.querySelector(sel) |
Premier élément correspondant au sélecteur CSS |
el.textContent = ... |
Insère du texte sûr (pas d’interprétation HTML) |
el.addEventListener(type, fn) |
Écoute un événement |
e.preventDefault() |
Annule l’action par défaut (rechargement, etc.) |
e.target.closest(sel) |
Remonte au premier ancêtre correspondant (délégation) |
fetch(url, options) |
Requête HTTP ; renvoie une promesse de Response |
response.ok / .json() |
Statut 2xx / corps JSON (promesse) |
new AbortController() |
Permet d’annuler une requête via son signal |
💪 À vous de jouer
Ajoutez un bouton « Supprimer » à chaque ligne et gérez sa suppression par délégation, sur le même écouteur que les cases à cocher. Indice : distinguez la cible avec une classe CSS ou evenement.target.tagName.
Voir une solution
// dans rendreListe, après le span :
const supprimer = document.createElement("button");
supprimer.textContent = "Supprimer";
supprimer.className = "supprimer";
li.append(supprimer);
// un seul écouteur "click" délégué sur la liste
liste.addEventListener("click", (evenement) => {
if (!evenement.target.classList.contains("supprimer")) return;
const id = Number(evenement.target.closest("li").dataset.id);
taches = taches.filter(t => t.id !== id); // retire la tâche
rendreListe(taches);
});
Tutoriels liés
- Promesses et async/await — la base de
fetchet de l’annulation. - Modules ES — pour ranger
ui.js,donnees.jset le reste proprement.
Pour aller plus loin
- 🔝 Retour au guide : JavaScript moderne : le guide complet
- MDN — Utiliser l’API Fetch
- Tutoriel suivant : Les nouveautés d’ECMAScript 2025
FAQ
Pourquoi fetch ne déclenche-t-il pas d’erreur sur un statut 404 ?
Par conception : fetch considère qu’une réponse reçue, même en erreur, est un succès de communication. Seule une panne réseau (serveur injoignable, requête annulée) rejette la promesse. C’est à vous de tester response.ok et de lever une erreur si besoin.
Quand utiliser la délégation d’événements plutôt qu’un écouteur par élément ?
Dès que les éléments sont nombreux ou ajoutés/retirés dynamiquement, comme une liste de tâches. Un seul écouteur sur le conteneur gère tout, présents et futurs, et évite de jongler avec l’attachement et le détachement.
textContent ou innerHTML ?
textContent par défaut : il insère du texte sans l’interpréter, ce qui protège des injections. Réservez innerHTML à du HTML que vous contrôlez entièrement, jamais à des données venant de l’utilisateur ou d’un serveur.
Faut-il toujours un debounce sur une recherche ?
Dès qu’une frappe déclenche une opération coûteuse (requête réseau, filtrage lourd), oui. Le debounce attend la fin de la saisie ; combiné à AbortController, il évite les requêtes superflues et les résultats périmés.