À propos de ce cours
Présentation du TP
- Chapitre : 20 – Le stockage des données dans le navigateur.
- Durée conseillée : 4 heures.
- Prérequis : les 3 leçons du chapitre 20, le TP 12 (To-Do List), JSON (chapitre 19), VS Code avec Live Server, un compte GitHub.
- Sujet : la To-Do List du TP 12 fonctionne, mais elle a un gros défaut : toutes les tâches disparaissent quand on recharge la page. Vous allez la rendre persistante avec le
localStorage, puis l'améliorer avec lesessionStorage(filtre et brouillon), l'événementstorage(plusieurs onglets), un cookie (thème) et un export / import des tâches.

Objectifs :
- Sauvegarder et recharger un tableau d'objets avec
localStorageet JSON. - Écrire un code de stockage robuste (données absentes, abîmées, stockage plein).
- Choisir le bon stockage pour chaque donnée :
localStorage,sessionStorageou cookie. - Organiser le code en modules et séparer l'état, le stockage et l'affichage.
Partie 1 Repartir de la To-Do List du TP 12
- Créez sur GitHub un dépôt public
tp20-todo-localstorageavec unREADME.md, clonez-le et ouvrez-le dans VS Code. - Copiez votre To-Do List du TP 12, ou la version de départ ci-dessous, dans cette arborescence :
tp20-todo-localstorage/
├── index.html
├── css/
│ └── style.css
└── js/
└── app.js
index.html:
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>To-Do List</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.9/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link href="css/style.css" rel="stylesheet" />
<script src="js/app.js" defer></script>
</head>
<body class="bg-body-tertiary">
<main class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-4">📝 Ma To-Do List</h1>
<form id="form-tache" class="d-flex gap-2 mb-3">
<input
id="champ-tache"
class="form-control"
placeholder="Nouvelle tâche…"
required
maxlength="120"
aria-label="Nouvelle tâche"
/>
<select id="champ-priorite" class="form-select w-auto" aria-label="Priorité">
<option value="normale">Normale</option>
<option value="haute">Haute</option>
</select>
<button class="btn btn-primary" type="submit">Ajouter</button>
</form>
<ul id="liste" class="list-group mb-3"></ul>
<div class="d-flex justify-content-between align-items-center">
<span id="compteur" class="text-body-secondary small"></span>
<button id="btn-vider" class="btn btn-sm btn-outline-danger" type="button">
Supprimer les tâches terminées
</button>
</div>
</main>
</body>
</html>
css/style.css:
.tache.faite .texte {
text-decoration: line-through;
color: var(--bs-secondary-color);
}
.tache.haute {
border-left: 4px solid var(--bs-danger);
}
.tache .texte {
flex: 1;
cursor: pointer;
word-break: break-word;
}
js/app.js:
// TP 12 – To-Do List (version de départ du TP 20)
// Problème : tout est perdu quand on recharge la page !
let taches = [];
const form = document.getElementById("form-tache");
const champ = document.getElementById("champ-tache");
const champPriorite = document.getElementById("champ-priorite");
const liste = document.getElementById("liste");
const compteur = document.getElementById("compteur");
function creerTache(texte, priorite) {
return { id: Date.now(), texte, faite: false, priorite };
}
function afficher() {
liste.replaceChildren();
for (const tache of taches) {
const li = document.createElement("li");
li.className = `list-group-item d-flex align-items-center gap-2 tache ${tache.priorite}`;
if (tache.faite) {
li.classList.add("faite");
}
li.dataset.id = tache.id;
const caseCocher = document.createElement("input");
caseCocher.type = "checkbox";
caseCocher.className = "form-check-input m-0";
caseCocher.checked = tache.faite;
const texte = document.createElement("span");
texte.className = "texte";
texte.textContent = tache.texte;
const supprimer = document.createElement("button");
supprimer.type = "button";
supprimer.className = "btn btn-sm btn-outline-danger supprimer";
supprimer.textContent = "✕";
supprimer.setAttribute("aria-label", `Supprimer « ${tache.texte} »`);
li.append(caseCocher, texte, supprimer);
liste.append(li);
}
const restantes = taches.filter((tache) => !tache.faite).length;
compteur.textContent = `${restantes} tâche(s) restante(s) sur ${taches.length}`;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const texte = champ.value.trim();
if (texte === "") {
return;
}
taches.push(creerTache(texte, champPriorite.value));
champ.value = "";
afficher();
});
liste.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li) {
return;
}
const id = Number(li.dataset.id);
if (event.target.matches(".supprimer")) {
taches = taches.filter((tache) => tache.id !== id);
} else if (event.target.matches("input[type=checkbox], .texte")) {
const trouvee = taches.find((tache) => tache.id === id);
trouvee.faite = !trouvee.faite;
}
afficher();
});
document.getElementById("btn-vider").addEventListener("click", () => {
taches = taches.filter((tache) => !tache.faite);
afficher();
});
afficher();
- Lancez la page avec Live Server, ajoutez trois tâches, cochez-en une, puis appuyez sur F5. Constatez le problème.
- Relisez le code et expliquez dans le
README.md(3 ou 4 lignes) où sont rangées les tâches et pourquoi elles disparaissent.
Commit : git add . puis git commit -m "Partie 1 : To-Do List de départ" et git push.
Partie 2 Sauvegarder les tâches
- Créez une constante
CLE_TACHES = "todo:taches:v1". Le préfixetodo:évite les conflits avec d'autres applications du même site, etv1permettra de faire évoluer le format plus tard. - Écrivez une fonction
sauvegarderTaches()qui enregistre le tableautachesen JSON dans lelocalStorage. - Repérez tous les endroits du code qui modifient le tableau (ajout, case cochée, suppression, suppression des tâches terminées) et appelez
sauvegarderTaches()après chaque modification. - Pour ne rien oublier, regroupez ces trois actions (modifier le tableau, sauvegarder, afficher) dans une seule fonction
mettreAJour(nouvellesTaches), et utilisez-la partout :
function mettreAJour(nouvellesTaches) {
taches = nouvellesTaches;
sauvegarderTaches(taches);
afficher();
}
// Exemple : suppression
mettreAJour(taches.filter((tache) => tache.id !== id));
Test : ajoutez des tâches et vérifiez dans DevTools → Application → Local storage que la clé todo:taches:v1 contient bien un tableau JSON qui évolue à chaque action.
Commit : "Partie 2 : sauvegarde dans le localStorage".
Partie 3 Recharger les tâches, sans jamais planter
- Écrivez
chargerTaches()qui renvoie le tableau enregistré, ou un tableau vide si la clé n'existe pas. Au démarrage :let taches = chargerTaches();. - Rendez-la robuste:
- entourez
JSON.parse()d'untry / catch(texte abîmé) ; - si le résultat n'est pas un tableau (
Array.isArray()), renvoyez[]; - écrivez
estTacheValide(tache)qui vérifie queidest une chaîne,texteune chaîne non vide,faiteun booléen etprioritel'une des valeurs autorisées, et ne gardez que les tâches valides (filter).
- entourez
- Faites renvoyer à
sauvegarderTaches()trueoufalse(try / catchautour desetItem()). En cas d'échec, affichez dans une zone#message(alerte Bootstrap) : « ⚠️ Sauvegarde impossible : vos modifications seront perdues à la fermeture. »
Tests (dans la console, puis F5 après chacun) :
localStorage.setItem("todo:taches:v1", "{abc"): la liste est vide, sans erreur rouge.localStorage.setItem("todo:taches:v1", '[{"texte":42}, null]'): liste vide, sans erreur.- Stockage plein :
Storage.prototype.setItem = () => { throw new DOMException("plein", "QuotaExceededError"); }, puis ajoutez une tâche : le message s'affiche.
Commit : "Partie 3 : chargement robuste".
Partie 4 Organiser le code en modules
Le fichier app.js devient long. Séparez-le en trois modules ES (<script type="module" src="js/app.js">) :
| Fichier | Contenu | Accès |
|---|---|---|
js/taches.js | Fonctions « pures » : creerTache, estTacheValide, basculer, supprimer, supprimerTerminees, filtrer | Ni DOM, ni stockage |
js/stockage.js | chargerTaches, sauvegarderTaches (et plus tard le filtre, le brouillon, le cookie) | Stockage uniquement |
js/app.js | État, affichage et événements | DOM |
Profitez-en pour corriger deux défauts de la version de départ :
- Identifiants :
Date.now()peut donner deux fois le même nombre (ajout très rapide, import de tâches). Utilisezcrypto.randomUUID(), qui génère un identifiant unique de 36 caractères. Attention : ledatasetrenvoie une chaîne, ne la convertissez plus avecNumber(). - Immuabilité : au lieu de modifier une tâche directement (
tache.faite = !tache.faite),basculer()renvoie un nouveau tableau avecmap()et la décomposition{ ...tache, faite: !tache.faite }.
Exemple attendu pour js/taches.js:
// Fonctions « pures » sur le tableau des tâches : elles ne touchent ni au DOM ni au stockage
export const PRIORITES = ["normale", "haute"];
export function creerTache(texte, priorite = "normale") {
return {
id: crypto.randomUUID(),
texte: texte.trim(),
faite: false,
priorite: PRIORITES.includes(priorite) ? priorite : "normale",
creeLe: new Date().toISOString(),
};
}
export function estTacheValide(tache) {
return (
tache !== null &&
typeof tache === "object" &&
typeof tache.id === "string" &&
typeof tache.texte === "string" &&
tache.texte.trim() !== "" &&
typeof tache.faite === "boolean" &&
PRIORITES.includes(tache.priorite)
);
}
export function basculer(taches, id) {
return taches.map((tache) => (tache.id === id ? { ...tache, faite: !tache.faite } : tache));
}
export function supprimer(taches, id) {
return taches.filter((tache) => tache.id !== id);
}
export function supprimerTerminees(taches) {
return taches.filter((tache) => !tache.faite);
}
export function filtrer(taches, filtre) {
if (filtre === "actives") {
return taches.filter((tache) => !tache.faite);
}
if (filtre === "terminees") {
return taches.filter((tache) => tache.faite);
}
return taches;
}
Ajoutez aussi la date de création creeLe (format ISO, new Date().toISOString()) : JSON ne sait pas stocker un objet Date.
Commit : "Partie 4 : modules, identifiants uniques".
Partie 5 Filtres et brouillon dans le sessionStorage
- Ajoutez trois boutons de filtre Toutes / Actives / Terminées (groupe de boutons Bootstrap avec des attributs
data-filtre) et une seule écoute par délégation. Le bouton actif reçoit la classeactiveetaria-pressed="true". Affichez « Aucune tâche à afficher. » quand la liste filtrée est vide. - Le filtre choisi est mémorisé dans le
sessionStorage(clétodo:filtre) : il survit à un rechargement, mais un nouvel onglet repart sur « Toutes ». - Le texte en cours de saisie dans le champ est enregistré à chaque frappe (événement
input) dans lesessionStorage(clétodo:brouillon), restauré au chargement, et effacé après l'ajout. - Dans le
README.md, expliquez en une phrase pourquoi on a choisi lesessionStorageet non lelocalStoragepour ces deux données.
Commit : "Partie 5 : filtres et brouillon (sessionStorage)".
Partie 6 Plusieurs onglets à jour
- Ouvrez l'application dans deux onglets côte à côte. Ajoutez une tâche dans le premier : le second n'est pas à jour, et s'il modifie à son tour la liste, il écrase la tâche ajoutée. Vérifiez-le.
- Écoutez l'événement
storagesurwindow: sievent.keyvautCLE_TACHES(ounullaprès unclear()), rechargez les tâches avecchargerTaches()et réaffichez-les. - Refaites le test : les deux onglets restent synchronisés.
Commit : "Partie 6 : synchronisation entre onglets".
Partie 7 Le thème dans un cookie
- Ajoutez en haut à droite un bouton « 🌙 Thème sombre » / « ☀️ Thème clair ». Le thème est appliqué avec l'attribut Bootstrap
data-bs-theme="dark"sur l'élément<html>(document.documentElement.dataset.bsTheme). - Le choix est enregistré dans un cookie
themevalable un an, avecpath=/etSameSite=Lax. ÉcrivezlireCookie(nom)etecrireCookie(nom, valeur, jours)dansstockage.js(voir la leçon sur les cookies). - Au chargement, appliquez le thème lu dans le cookie.
- Pourquoi un cookie ici ? Si un jour la page est générée par un serveur (PHP, Symfony…), il pourra lire le cookie et envoyer directement la page dans le bon thème, sans « flash » blanc. Vérifiez dans l'onglet Réseau que le cookie part bien avec la requête de la page.
Commit : "Partie 7 : thème mémorisé dans un cookie".
Partie 8 Exporter et importer ses tâches
Le localStorage peut être effacé (nettoyage du navigateur, changement d'ordinateur). Offrez une sauvegarde à l'utilisateur.
- Exporter : un bouton « ⬇️ Exporter (JSON) » télécharge un fichier
taches-AAAA-MM-JJ.json:
const blob = new Blob([JSON.stringify(taches, null, 2)], { type: "application/json" });
const lien = document.createElement("a");
lien.href = URL.createObjectURL(blob);
lien.download = `taches-${new Date().toISOString().slice(0, 10)}.json`;
lien.click();
URL.revokeObjectURL(lien.href);
- Importer : un
<input type="file" accept=".json">(caché dans un<label>stylé en bouton). À l'événementchange, lisez le fichier avecawait fichier.text(), puis :- vérifiez que c'est un tableau de tâches valides, sinon affichez « ❌ Ce fichier n'est pas un export valide de la To-Do List. » ;
- ajoutez seulement les tâches dont l'
idn'existe pas encore (utilisez unSet) ; - affichez « ✅ 3 tâche(s) importée(s). ».
- Affichez en bas à droite la taille de la sauvegarde :
new Blob([texte]).sizedonne le nombre d'octets.
Commit : "Partie 8 : export et import JSON".
Partie 9 Recette, publication et README
9.1 – Recette. Recopiez ce tableau dans le README.md et complétez la colonne « Résultat » :
| N° | Scénario | Résultat attendu | Résultat |
|---|---|---|---|
| R1 | Ajouter 3 tâches, en cocher une, F5 | Les 3 tâches, l'état coché et la priorité sont conservés | |
| R2 | Fermer le navigateur, le rouvrir | Les tâches sont toujours là | |
| R3 | Filtre « Actives », F5, puis nouvel onglet | Filtre conservé après F5 ; « Toutes » dans le nouvel onglet | |
| R4 | Taper un texte sans valider, F5 | Le texte est toujours dans le champ | |
| R5 | Deux onglets : ajout dans l'un | L'autre se met à jour | |
| R6 | Thème sombre, fermer, rouvrir | Thème sombre conservé (cookie theme=dark) | |
| R7 | JSON abîmé dans le localStorage, F5 | Liste vide, aucune erreur dans la console | |
| R8 | Stockage plein simulé, ajout | Message d'avertissement | |
| R9 | Exporter, vider le localStorage, importer | Toutes les tâches reviennent | |
| R10 | Importer deux fois le même fichier | « 0 tâche(s) importée(s) », pas de doublon | |
| R11 | Importer un fichier JSON quelconque | Message d'erreur, liste inchangée | |
| R12 | Tâche <img src=x onerror=alert(1)>, F5 | Le texte s'affiche tel quel, aucune alerte |
9.2 – Publication. Publiez le site avec GitHub Pages (Settings → Pages → Deploy from a branch → main / root). Remarquez que les tâches enregistrées en local (Live Server) ne sont pas visibles sur le site publié : ce n'est pas la même origine.
9.3 – README. Présentation, lien du site, capture d'écran, tableau « Donnée → stockage choisi → justification » (tâches, filtre, brouillon, thème), tableau de recette.
Commit : "Partie 9 : recette, README et publication".
Bonus Pour aller plus loin
- Migration : si l'ancienne clé
tachesdu TP 12 existe (tableau de textes ou d'objets avec desidnumériques), convertissez ses données au nouveau formatv1, enregistrez-les, puis supprimez l'ancienne clé. - Édition : un double-clic sur le texte le transforme en champ de saisie ; Entrée enregistre, Échap annule.
- Échéance : ajoutez une date limite (
<input type="date">) ; les tâches en retard s'affichent en rouge. - Version IndexedDB : sur une branche Git
indexeddb, remplacezstockage.jspar une version utilisant IndexedDB (un magasintachesaveckeyPath: "id"). Seul ce module doit changer, mais ses fonctions deviennent asynchrones : adaptezapp.js.
Livrables et barème
- Le lien du dépôt GitHub (au minimum 8 commits) et le lien du site publié.
- Le
README.mdavec les explications demandées et le tableau de recette complété.
| Critère | Points |
|---|---|
Sauvegarde après chaque modification, fonction mettreAJour() (partie 2) | 3 |
| Chargement robuste : JSON abîmé, validation, stockage plein (partie 3) | 3 |
| Modules, fonctions pures, identifiants uniques (partie 4) | 3 |
Filtres et brouillon dans le sessionStorage (partie 5) | 2 |
| Synchronisation entre onglets (partie 6) | 2 |
| Thème dans un cookie avec les bons attributs (partie 7) | 2 |
| Export et import JSON avec contrôle et sans doublon (partie 8) | 3 |
Qualité : aucun innerHTML avec des données, recette, README, commits réguliers, site publié | 2 |
| Total | 20 |
| Bonus | +2 |
localStorage, le sessionStorage et les cookies, et vous pouvez les modifier pour tester les cas difficiles.