Prérequis
- Niveau : bases JavaScript, manipulation du DOM (cf. DOM) et événements.
- Outils : VS Code + Live Server, navigateur moderne (DevTools onglet Application → localStorage pour observer).
- Temps estimé : 1 h 30.
Pourquoi une to-do list ?
C’est le projet d’apprentissage par excellence : il combine état applicatif, manipulation du DOM, événements, persistence (localStorage), filtrage, édition. Maîtriser ce pattern, c’est avoir compris les briques d’une vraie application web.
Ce que vous allez construire
Une application de to-do list complète avec ajout, suppression, modification et persistance des données dans le localStorage. Ce projet couvre les concepts fondamentaux de JavaScript : manipulation du DOM, gestion des événements, localStorage, et structuration du code. Vous pouvez tester le résultat final directement dans votre navigateur.
Le HTML
<div class="todo-app">
<h1>Mes tâches</h1>
<form id="todoForm" class="todo-form">
<input type="text" id="todoInput" placeholder="Ajouter une tâche..." required>
<button type="submit">Ajouter</button>
</form>
<div class="todo-filters">
<button class="filter-btn actif" data-filter="all">Toutes</button>
<button class="filter-btn" data-filter="active">Actives</button>
<button class="filter-btn" data-filter="completed">Terminées</button>
</div>
<ul id="todoList" class="todo-list"></ul>
<p class="todo-count"><span id="todoCount">0</span> tâche(s) restante(s)</p>
</div>
Le CSS
.todo-app {
max-width: 500px;
margin: 40px auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.todo-form input {
flex: 1;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
}
.todo-form input:focus {
outline: none;
border-color: #4a90d9;
}
.todo-form button {
padding: 12px 24px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
transition: opacity 0.3s;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
flex: 1;
font-size: 16px;
}
.todo-delete {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 18px;
opacity: 0;
transition: opacity 0.2s;
}
.todo-item:hover .todo-delete {
opacity: 1;
}
.filter-btn {
padding: 6px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 20px;
cursor: pointer;
margin-right: 8px;
}
.filter-btn.actif {
background: #4a90d9;
color: white;
border-color: #4a90d9;
}
Le JavaScript complet
// État de l'application
let todos = JSON.parse(localStorage.getItem('todos')) || [];
let currentFilter = 'all';
// Éléments du DOM
const form = document.getElementById('todoForm');
const input = document.getElementById('todoInput');
const list = document.getElementById('todoList');
const countEl = document.getElementById('todoCount');
const filterBtns = document.querySelectorAll('.filter-btn');
// Générer un ID unique
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
// Sauvegarder dans localStorage
function save() {
localStorage.setItem('todos', JSON.stringify(todos));
}
// Afficher les tâches
function render() {
const filtered = todos.filter(todo => {
if (currentFilter === 'active') return !todo.completed;
if (currentFilter === 'completed') return todo.completed;
return true;
});
list.innerHTML = filtered.map(todo =>
'<li class="todo-item ' + (todo.completed ? 'completed' : '') + '" data-id="' + todo.id + '">' +
'<input type="checkbox" ' + (todo.completed ? 'checked' : '') + '>' +
'<span class="todo-text">' + escapeHtml(todo.text) + '</span>' +
'<button class="todo-delete">✕</button>' +
'</li>'
).join('');
// Compter les tâches actives
const activeCount = todos.filter(t => !t.completed).length;
countEl.textContent = activeCount;
}
// Échapper le HTML pour éviter les XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Ajouter une tâche
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
todos.push({ id: generateId(), text, completed: false });
save();
render();
input.value = '';
input.focus();
});
// Cocher/décocher et supprimer (délégation d'événements)
list.addEventListener('click', (e) => {
const item = e.target.closest('.todo-item');
if (!item) return;
const id = item.dataset.id;
if (e.target.type === 'checkbox') {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
save();
render();
}
if (e.target.classList.contains('todo-delete')) {
todos = todos.filter(t => t.id !== id);
save();
render();
}
});
// Double-clic pour éditer
list.addEventListener('dblclick', (e) => {
if (!e.target.classList.contains('todo-text')) return;
const item = e.target.closest('.todo-item');
const id = item.dataset.id;
const todo = todos.find(t => t.id === id);
const editInput = document.createElement('input');
editInput.type = 'text';
editInput.value = todo.text;
editInput.className = 'todo-edit';
e.target.replaceWith(editInput);
editInput.focus();
function finishEdit() {
const newText = editInput.value.trim();
if (newText) {
todo.text = newText;
save();
}
render();
}
editInput.addEventListener('blur', finishEdit);
editInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') finishEdit();
if (ev.key === 'Escape') render(); // Annuler
});
});
// Filtres
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('actif'));
btn.classList.add('actif');
currentFilter = btn.dataset.filter;
render();
});
});
// Affichage initial
render();
Concepts clés dans ce projet
- localStorage : les données persistent entre les sessions du navigateur.
JSON.stringifypour sauvegarder,JSON.parsepour lire. - Délégation d’événements : un seul écouteur sur la liste parente gère les clics sur tous les éléments enfants, même ceux ajoutés dynamiquement.
- Échappement HTML : la fonction
escapeHtml()empêche les attaques XSS si un utilisateur tape du HTML dans le champ. - État centralisé : toute l’application est pilotée par le tableau
todos. On modifie l’état, on sauvegarde, on re-render.
Erreurs fréquentes
Faille XSS si on injecte avec innerHTML
Cause : on insère le texte utilisateur directement dans innerHTML → un utilisateur peut taper <script>...</script>.
Solution : appliquez escapeHtml() (déjà présent dans cet article), ou préférez textContent.
substr au lieu de substring ou slice
Cause : String.prototype.substr est déprécié (legacy).
Solution : utilisez str.substring(2) ou str.slice(2) en code récent.
Listeners ré-attachés à chaque render()
Cause : on rebinde un addEventListener sur chaque li à chaque rendu → fuite mémoire et bugs.
Solution : utilisez la délégation d’événements sur list (pattern utilisé dans cet article).
localStorage limité à 5-10 Mo
Cause : on stocke des données volumineuses (PJ, images en base64).
Solution : au-delà de quelques milliers d’items texte, basculez sur IndexedDB ou un backend léger.
Exercice d’extension
- Ajoutez un bouton « Supprimer les tâches terminées »
- Ajoutez le drag & drop pour réordonner les tâches
- Ajoutez des dates d’échéance avec un code couleur (rouge si en retard)
- Ajoutez des catégories (Travail, Personnel, Urgent)
Pour étoffer le tableau
- Manipuler le DOM
- Les événements JavaScript
- Système de filtrage en JS
- Référence : MDN — Web Storage
- Au-delà : MDN — IndexedDB
Etape 1 : preparer l’environnement de travail
Avant d’ecrire la premiere ligne de JavaScript, on prepare un dossier propre. Sur Windows, Linux ou macOS, l’idee reste la meme : un dossier, trois fichiers, un editeur. A Dakar, Abidjan ou Cotonou, beaucoup de developpeurs juniors travaillent depuis un cybercafe ou une connexion 4G partagee — un projet local sans build evite les surprises de bande passante.
mkdir todo-list && cd todo-list
touch index.html style.css app.js
code .
La commande code . ouvre VS Code dans le dossier courant. Si VS Code n’est pas installe, n’importe quel editeur fait l’affaire (Sublime, Notepad++, nano). Le signal de reussite : trois fichiers vides cote a cote dans l’explorateur de fichiers de l’editeur.
Etape 2 : ecrire le squelette HTML
Le HTML decrit la structure visible : un titre, un champ de saisie, un bouton d’ajout et une liste vide. On ne charge ni framework ni librairie — JavaScript natif suffit largement pour ce besoin.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Ma to-do list</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Mes taches du jour</h1>
<form id="form-todo">
<input id="champ" type="text" placeholder="Ajouter une tache" required>
<button type="submit">Ajouter</button>
</form>
<ul id="liste"></ul>
<script src="app.js"></script>
</body>
</html>
Ouvrir index.html dans un navigateur affiche le titre, le champ et le bouton. La liste reste vide : c’est normal, on n’a pas encore ecrit la logique.
Etape 3 : styliser sobrement avec CSS
Un design minimaliste suffit pour rester lisible sur un ecran de smartphone Android entry-level. On evite les ombres lourdes et les polices web qui ralentissent le premier rendu sur reseau lent.
body{font-family:system-ui,sans-serif;max-width:480px;margin:2rem auto;padding:1rem;}
h1{font-size:1.4rem;}
form{display:flex;gap:.5rem;margin-bottom:1rem;}
input{flex:1;padding:.5rem;border:1px solid #ccc;border-radius:4px;}
button{padding:.5rem 1rem;border:0;background:#0a66c2;color:#fff;border-radius:4px;cursor:pointer;}
ul{list-style:none;padding:0;}
li{display:flex;justify-content:space-between;padding:.6rem;border-bottom:1px solid #eee;}
li.done span{text-decoration:line-through;opacity:.5;}
Apres rechargement, l’interface ressemble a une mini-application mobile : champ et bouton alignes, liste prete a accueillir des elements. La classe done servira pour marquer une tache terminee.
Etape 4 : capturer la soumission du formulaire
On passe maintenant a app.js. Le premier reflexe : intercepter l’evenement submit pour eviter le rechargement de page par defaut, et lire la valeur saisie.
const form = document.getElementById('form-todo');
const champ = document.getElementById('champ');
const liste = document.getElementById('liste');
form.addEventListener('submit', (e) => {
e.preventDefault();
const texte = champ.value.trim();
if (!texte) return;
ajouterTache(texte);
champ.value = '';
champ.focus();
});
Le trim() elimine les espaces parasites. Le focus() renvoie le curseur dans le champ pour saisir la tache suivante sans toucher la souris — un detail UX qui change tout au quotidien.
Etape 5 : creer dynamiquement les elements de liste
La fonction ajouterTache construit un li avec deux elements : le texte et un bouton de suppression. On utilise document.createElement plutot que innerHTML pour eviter toute injection HTML si l’utilisateur colle du contenu inattendu.
function ajouterTache(texte){
const li = document.createElement('li');
const span = document.createElement('span');
span.textContent = texte;
const btn = document.createElement('button');
btn.textContent = 'X';
btn.setAttribute('aria-label','Supprimer');
li.append(span, btn);
liste.appendChild(li);
sauvegarder();
}
Apres saisie d’une tache, elle apparait dans la liste avec un bouton « X » a droite. La fonction sauvegarder() sera definie a l’etape 7.
Etape 6 : marquer comme fait et supprimer
On delegue les clics au conteneur ul plutot que d’attacher un listener par element. Cette technique, appelee delegation d’evenements, reste performante meme avec 200 taches.
liste.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
if (e.target.tagName === 'BUTTON'){
li.remove();
} else {
li.classList.toggle('done');
}
sauvegarder();
});
Cliquer sur le texte barre la tache (classe done appliquee). Cliquer sur le bouton X la retire. Chaque action declenche une sauvegarde locale.
Etape 7 : persister dans localStorage
Sans persistance, recharger la page efface tout. localStorage stocke des chaines de caracteres dans le navigateur, jusqu’a 5 Mo selon les navigateurs. Largement suffisant pour quelques centaines de taches.
function sauvegarder(){
const taches = [...liste.querySelectorAll('li')].map(li => ({
texte: li.querySelector('span').textContent,
fait: li.classList.contains('done')
}));
localStorage.setItem('todo', JSON.stringify(taches));
}
function charger(){
const data = JSON.parse(localStorage.getItem('todo') || '[]');
data.forEach(t => {
ajouterTache(t.texte);
if (t.fait) liste.lastChild.classList.add('done');
});
}
charger();
Au rechargement, les taches reapparaissent dans le meme etat. Pour verifier, ouvrir l’inspecteur du navigateur, onglet Application, section Local Storage : la cle todo contient le JSON serialise.
Etape 8 : tester sur mobile et publier
Pour partager le projet avec un ami a Bamako ou Lome, deux options simples. La premiere : heberger sur GitHub Pages (gratuit, HTTPS automatique). La seconde : Netlify Drop, qui accepte un dossier glisse-depose et publie en moins d’une minute.
git init
git add .
git commit -m "to-do list MVP"
gh repo create todo-list --public --source=. --push
gh repo edit --enable-pages --pages-branch=main
Sur Netlify Drop, glisser le dossier todo-list sur la zone de la page d’accueil. L’URL publique apparait immediatement. Tester depuis un smartphone : ajouter une tache, la cocher, la supprimer, recharger — tout doit persister par appareil.
Etape 9 : aller plus loin
Une fois la base solide, plusieurs extensions deviennent accessibles. Ajouter une date d’echeance avec <input type="date">. Filtrer les taches actives ou terminees via des boutons. Synchroniser avec une API REST quand vous serez pret pour le backend (Node.js, PHP, ou un BaaS comme Supabase). Pour le paiement Premium d’une version Pro a 2 950 FCFA par mois (environ 4,50 EUR au taux fixe 1 EUR = 655,957 FCFA), Mixx by Yas et Wave couvrent l’essentiel du marche senegalais.
Pour approfondir le DOM et les evenements, voir le positionnement CSS explique simplement. Pour structurer un projet plus ambitieux, jeter un oeil a la securite du cloud computing avant de pousser un backend en production.
Etape 10 : accessibilite et raccourcis clavier
Une to-do list reellement utilisable doit fonctionner sans souris. Les utilisateurs avec un trackpad capricieux, un clavier mecanique, ou simplement habitues aux raccourcis gagnent en vitesse. On ajoute deux comportements : la touche Entree dans le champ valide deja le formulaire (comportement natif), et on intercepte la touche Suppr sur une tache focussee pour la retirer.
liste.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace'){
const li = e.target.closest('li');
if (li){ li.remove(); sauvegarder(); }
}
});
Pour rendre les li focussables au clavier, ajouter tabindex="0" dans la fonction ajouterTache juste apres la creation du li. Tester ensuite avec la touche Tab : chaque tache reçoit le focus tour a tour, un anneau bleu apparait, la touche Suppr fonctionne immediatement.
Etape 11 : reorganiser par glisser-deposer (optionnel)
Pour les utilisateurs qui veulent prioriser visuellement, l’API HTML5 Drag and Drop permet de reordonner les taches sans librairie externe. Le code reste court et la logique se branche sur les memes evenements de sauvegarde.
let drag = null;
liste.addEventListener('dragstart', (e) => { drag = e.target; });
liste.addEventListener('dragover', (e) => { e.preventDefault(); });
liste.addEventListener('drop', (e) => {
const cible = e.target.closest('li');
if (cible && drag !== cible){
liste.insertBefore(drag, cible);
sauvegarder();
}
});
Penser a ajouter draggable="true" sur chaque li dans ajouterTache. Apres rechargement, l’ordre est respecte grace a la sauvegarde JSON qui preserve la sequence du tableau.
Etape 12 : checklist de mise en production
Avant de partager l’URL publique avec des utilisateurs reels, valider quatre points : la console du navigateur ne remonte aucune erreur rouge, le score Lighthouse Performance reste au-dessus de 90 sur mobile 4G, le HTTPS est actif (icone cadenas dans la barre d’adresse), et les donnees survivent a la fermeture complete du navigateur. Si l’un de ces points echoue, revenir aux etapes correspondantes plutot que de superposer des correctifs.