Créer une application To-Do simple avec React (débutants)
Ce tutoriel explique pas à pas comment créer une petite application “To-Do” (liste de tâches) avec React. L’objectif est de comprendre les bases : composants, état (state), rendu conditionnel, gestion des événements, listes, formulaires, persistance simple dans le navigateur, et un peu d’organisation de code.
Prérequis
- Savoir utiliser un terminal (ou l’invite de commande)
- Connaissances de base en HTML/CSS et JavaScript (variables, fonctions, tableaux, objets)
- Node.js installé (version récente recommandée)
Vérifier l’installation de Node.js et npm
Ouvrez un terminal et tapez :
node -v
npm -v
Vous devriez voir deux numéros de version. Si ce n’est pas le cas, installez Node.js depuis le site officiel (la version LTS est généralement la meilleure option).
1) Créer un projet React
Il existe plusieurs façons de démarrer un projet React. Pour un projet débutant, Vite est une excellente option : rapide, simple, moderne.
Créer le projet avec Vite
Dans votre terminal, placez-vous dans un dossier où vous voulez créer le projet, puis lancez :
npm create vite@latest todo-react-debutant -- --template react
Ensuite :
cd todo-react-debutant
npm install
npm run dev
Vite va afficher une adresse locale, souvent :
http://localhost:5173/
Ouvrez-la dans votre navigateur. Vous devriez voir la page de démarrage React.
2) Nettoyer le projet pour partir sur une base simple
Ouvrez le projet dans votre éditeur (par exemple VS Code).
Structure typique
Vous verrez notamment :
src/main.jsx: point d’entréesrc/App.jsx: composant principalsrc/index.css: styles globauxsrc/App.css: styles du composant App (selon le template)
Simplifier App.jsx
Remplacez le contenu de src/App.jsx par :
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
return (
<div className="app">
<h1>Ma To-Do</h1>
</div>
);
}
Simplifier App.css
Remplacez src/App.css par quelque chose de minimal (vous pourrez l’améliorer ensuite) :
.app {
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
h1 {
margin-bottom: 16px;
}
Vérifiez dans le navigateur : vous devez voir “Ma To-Do”.
3) Comprendre l’idée : composants + état + rendu
React fonctionne avec des composants (des fonctions qui retournent du JSX) et un état (des données qui changent dans le temps et déclenchent un nouveau rendu).
Une To-Do minimale a besoin de :
- Un champ texte pour écrire une tâche
- Un bouton pour l’ajouter
- Une liste de tâches
- La possibilité de marquer une tâche comme terminée
- La possibilité de supprimer une tâche
Nous allons construire cela progressivement.
4) Modéliser une tâche (structure de données)
Une tâche peut être représentée par un objet JavaScript, par exemple :
{
id: "un-identifiant-unique",
text: "Acheter du pain",
done: false
}
id: utile pour identifier une tâche de manière stable (important pour React lorsqu’on rend des listes).text: le contenu saisi.done: état terminé ou non.
5) Ajouter l’état : liste de tâches et champ de saisie
Modifiez src/App.jsx :
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
const [text, setText] = useState("");
const [todos, setTodos] = useState([]);
return (
<div className="app">
<h1>Ma To-Do</h1>
<form className="todo-form">
<input
type="text"
placeholder="Écrire une tâche..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Ajouter</button>
</form>
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
</div>
);
}
Explications importantes
useState("")crée un étattextinitialisé à une chaîne vide.value={text}rend l’input contrôlé : la valeur affichée provient de l’état React.onChangemet à jourtextà chaque frappe.todosest un tableau de tâches.todos.map(...)transforme le tableau en éléments<li>.key={t.id}est crucial : React l’utilise pour suivre les éléments de liste efficacement.
Pour le moment, la liste est vide car on n’ajoute rien. Il faut gérer la soumission du formulaire.
6) Ajouter une tâche (gestion d’événement onSubmit)
Dans un formulaire HTML, le bouton type="submit" déclenche l’événement submit. Par défaut, le navigateur recharge la page, ce qu’on ne veut pas dans une application React. On doit donc faire preventDefault().
Modifiez le formulaire :
import { useEffect, useMemo, useState } from "react";
import "./App.css";
function createId() {
// Méthode simple pour ce tutoriel.
// Pour un projet plus sérieux, on peut utiliser crypto.randomUUID() si disponible.
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
export default function App() {
const [text, setText] = useState("");
const [todos, setTodos] = useState([]);
function handleSubmit(e) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
const newTodo = {
id: createId(),
text: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setText("");
}
return (
<div className="app">
<h1>Ma To-Do</h1>
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Écrire une tâche..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Ajouter</button>
</form>
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
</div>
);
}
Pourquoi setTodos((prev) => ...) ?
C’est une bonne pratique : au lieu d’utiliser directement todos, on utilise la forme “fonctionnelle” de setTodos, qui reçoit la valeur précédente. Cela évite certains problèmes lorsque plusieurs mises à jour d’état s’enchaînent.
Pourquoi [newTodo, ...prev] ?
On ajoute la nouvelle tâche en haut de la liste. Vous pouvez faire l’inverse ([...prev, newTodo]) si vous préférez l’ajout en bas.
Testez : vous pouvez maintenant ajouter des tâches.
7) Marquer une tâche comme terminée
On veut pouvoir cliquer sur une tâche (ou un bouton) pour basculer done.
Principe : ne pas muter l’état
En React, on évite de modifier directement les objets/tablaux de l’état (par exemple todos[0].done = true). On crée plutôt une nouvelle version du tableau.
Ajoutez une fonction :
function toggleTodo(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}
mapcrée un nouveau tableau.- Pour l’élément ciblé, on crée un nouvel objet
{ ...t, done: !t.done }. - Les autres éléments restent identiques.
Mettre à jour le rendu
Modifiez la liste :
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id} className={t.done ? "done" : ""}>
<label className="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={() => toggleTodo(t.id)}
/>
<span>{t.text}</span>
</label>
</li>
))}
</ul>
Ici :
- La case à cocher reflète
checked={t.done}. onChangebascule l’état.- On ajoute une classe CSS
donepour styliser.
Ajouter du style
Dans src/App.css, ajoutez :
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-form input {
flex: 1;
padding: 10px 12px;
border: 1px solid #cfcfcf;
border-radius: 8px;
}
.todo-form button {
padding: 10px 14px;
border: 1px solid #cfcfcf;
border-radius: 8px;
background: #111;
color: #fff;
cursor: pointer;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 8px;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
}
.todo-list li {
border: 1px solid #e3e3e3;
border-radius: 10px;
padding: 10px 12px;
background: #fff;
}
.todo-list li.done span {
text-decoration: line-through;
color: #777;
}
8) Supprimer une tâche
Ajoutons un bouton “Supprimer” à chaque item.
Fonction de suppression
function deleteTodo(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
filterretourne un nouveau tableau sans l’élément ciblé.
Mettre à jour le rendu
Remplacez la partie <label ...> par une structure avec un bouton :
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id} className={t.done ? "done" : ""}>
<div className="todo-row">
<label className="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={() => toggleTodo(t.id)}
/>
<span>{t.text}</span>
</label>
<button
className="danger"
type="button"
onClick={() => deleteTodo(t.id)}
aria-label={`Supprimer : ${t.text}`}
title="Supprimer"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
Style du bouton
Ajoutez dans App.css :
.todo-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
button.danger {
padding: 8px 10px;
border: 1px solid #e0b4b4;
border-radius: 8px;
background: #fff5f5;
color: #7a1f1f;
cursor: pointer;
}
9) Afficher un message si la liste est vide (rendu conditionnel)
Le rendu conditionnel est une base de React : on affiche quelque chose selon une condition.
Sous le formulaire, vous pouvez ajouter :
{todos.length === 0 ? (
<p className="empty">Aucune tâche pour le moment. Ajoutez-en une !</p>
) : (
<ul className="todo-list">
{todos.map((t) => (
<li key={t.id} className={t.done ? "done" : ""}>
<div className="todo-row">
<label className="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={() => toggleTodo(t.id)}
/>
<span>{t.text}</span>
</label>
<button
className="danger"
type="button"
onClick={() => deleteTodo(t.id)}
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
Et un style :
.empty {
color: #666;
margin-top: 8px;
}
10) Ajouter des filtres : toutes / actives / terminées
Un classique des To-Do : filtrer l’affichage.
Ajouter un état filter
Dans App.jsx :
const [filter, setFilter] = useState("all"); // "all" | "active" | "done"
Calculer la liste filtrée avec useMemo
useMemo permet de mémoriser un calcul. Ce n’est pas obligatoire ici, mais c’est pédagogique : on calcule une “vue” des données selon todos et filter.
const visibleTodos = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.done);
if (filter === "done") return todos.filter((t) => t.done);
return todos;
}, [todos, filter]);
Ajouter des boutons de filtre
Sous le formulaire :
<div className="filters" role="group" aria-label="Filtres">
<button
type="button"
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
Toutes
</button>
<button
type="button"
className={filter === "active" ? "active" : ""}
onClick={() => setFilter("active")}
>
Actives
</button>
<button
type="button"
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
Terminées
</button>
</div>
Utiliser visibleTodos dans le rendu
Remplacez todos par visibleTodos dans le map.
Et pour le message vide, vous pouvez distinguer :
- liste totale vide
- filtre qui ne retourne rien
Exemple :
{todos.length === 0 ? (
<p className="empty">Aucune tâche pour le moment. Ajoutez-en une !</p>
) : visibleTodos.length === 0 ? (
<p className="empty">Aucune tâche ne correspond à ce filtre.</p>
) : (
<ul className="todo-list">
{visibleTodos.map((t) => (
<li key={t.id} className={t.done ? "done" : ""}>
<div className="todo-row">
<label className="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={() => toggleTodo(t.id)}
/>
<span>{t.text}</span>
</label>
<button
className="danger"
type="button"
onClick={() => deleteTodo(t.id)}
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
Style des filtres
Dans App.css :
.filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filters button {
padding: 8px 10px;
border: 1px solid #d8d8d8;
border-radius: 999px;
background: #fff;
cursor: pointer;
}
.filters button.active {
border-color: #111;
background: #111;
color: #fff;
}
11) Ajouter des statistiques (tâches restantes, terminées)
C’est utile pour pratiquer les calculs dérivés.
Ajoutez :
const remainingCount = useMemo(
() => todos.filter((t) => !t.done).length,
[todos]
);
const doneCount = useMemo(
() => todos.filter((t) => t.done).length,
[todos]
);
Affichez sous les filtres :
<p className="stats">
Restantes : <strong>{remainingCount}</strong> — Terminées :{" "}
<strong>{doneCount}</strong>
</p>
Style :
.stats {
margin: 0 0 16px;
color: #333;
}
12) Persister les tâches avec localStorage
Sans persistance, tout disparaît au rechargement. localStorage est une solution simple côté navigateur.
Principe
- Au démarrage : lire
localStorage, si des données existent, les charger danstodos. - À chaque modification de
todos: sauvegarder danslocalStorage.
Charger au montage avec useEffect
Ajoutez ce code dans App.jsx :
useEffect(() => {
const raw = localStorage.getItem("todos-v1");
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
setTodos(parsed);
}
} catch {
// Si le JSON est invalide, on ignore
}
}, []);
- Le tableau de dépendances
[]signifie : exécuter une seule fois au montage. - On protège avec
try/catchcarJSON.parsepeut échouer.
Sauvegarder à chaque changement
Ajoutez un autre useEffect :
useEffect(() => {
localStorage.setItem("todos-v1", JSON.stringify(todos));
}, [todos]);
Dès que todos change, on sauvegarde.
13) Améliorer l’expérience : désactiver “Ajouter” si vide, et focus
Désactiver le bouton
Dans le bouton “Ajouter” :
<button type="submit" disabled={!text.trim()}>
Ajouter
</button>
Ajoutez un style :
.todo-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Remettre le focus dans l’input après ajout (optionnel)
Pour cela, on utilise useRef.
- Importer
useRef:
import { useEffect, useMemo, useRef, useState } from "react";
- Créer la ref :
const inputRef = useRef(null);
- L’attacher à l’input :
<input
ref={inputRef}
type="text"
placeholder="Écrire une tâche..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
- Après l’ajout, remettre le focus :
Dans handleSubmit, après setText("") :
inputRef.current?.focus();
14) Code final complet de App.jsx
Voici une version complète regroupant tout ce que nous avons fait. Remplacez entièrement src/App.jsx par :
import { useEffect, useMemo, useRef, useState } from "react";
import "./App.css";
function createId() {
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
export default function App() {
const [text, setText] = useState("");
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all"); // "all" | "active" | "done"
const inputRef = useRef(null);
useEffect(() => {
const raw = localStorage.getItem("todos-v1");
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) setTodos(parsed);
} catch {
// ignore
}
}, []);
useEffect(() => {
localStorage.setItem("todos-v1", JSON.stringify(todos));
}, [todos]);
function handleSubmit(e) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
const newTodo = {
id: createId(),
text: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setText("");
inputRef.current?.focus();
}
function toggleTodo(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}
function deleteTodo(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
const visibleTodos = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.done);
if (filter === "done") return todos.filter((t) => t.done);
return todos;
}, [todos, filter]);
const remainingCount = useMemo(
() => todos.filter((t) => !t.done).length,
[todos]
);
const doneCount = useMemo(() => todos.filter((t) => t.done).length, [todos]);
return (
<div className="app">
<h1>Ma To-Do</h1>
<form className="todo-form" onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
placeholder="Écrire une tâche..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit" disabled={!text.trim()}>
Ajouter
</button>
</form>
<div className="filters" role="group" aria-label="Filtres">
<button
type="button"
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
Toutes
</button>
<button
type="button"
className={filter === "active" ? "active" : ""}
onClick={() => setFilter("active")}
>
Actives
</button>
<button
type="button"
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
Terminées
</button>
</div>
<p className="stats">
Restantes : <strong>{remainingCount}</strong> — Terminées :{" "}
<strong>{doneCount}</strong>
</p>
{todos.length === 0 ? (
<p className="empty">Aucune tâche pour le moment. Ajoutez-en une !</p>
) : visibleTodos.length === 0 ? (
<p className="empty">Aucune tâche ne correspond à ce filtre.</p>
) : (
<ul className="todo-list">
{visibleTodos.map((t) => (
<li key={t.id} className={t.done ? "done" : ""}>
<div className="todo-row">
<label className="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={() => toggleTodo(t.id)}
/>
<span>{t.text}</span>
</label>
<button
className="danger"
type="button"
onClick={() => deleteTodo(t.id)}
aria-label={`Supprimer : ${t.text}`}
title="Supprimer"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
15) Code final complet de App.css
Remplacez src/App.css par :
.app {
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
h1 {
margin-bottom: 16px;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-form input {
flex: 1;
padding: 10px 12px;
border: 1px solid #cfcfcf;
border-radius: 8px;
}
.todo-form button {
padding: 10px 14px;
border: 1px solid #cfcfcf;
border-radius: 8px;
background: #111;
color: #fff;
cursor: pointer;
}
.todo-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filters button {
padding: 8px 10px;
border: 1px solid #d8d8d8;
border-radius: 999px;
background: #fff;
cursor: pointer;
}
.filters button.active {
border-color: #111;
background: #111;
color: #fff;
}
.stats {
margin: 0 0 16px;
color: #333;
}
.empty {
color: #666;
margin-top: 8px;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 8px;
}
.todo-list li {
border: 1px solid #e3e3e3;
border-radius: 10px;
padding: 10px 12px;
background: #fff;
}
.todo-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
}
.todo-list li.done span {
text-decoration: line-through;
color: #777;
}
button.danger {
padding: 8px 10px;
border: 1px solid #e0b4b4;
border-radius: 8px;
background: #fff5f5;
color: #7a1f1f;
cursor: pointer;
}
16) Lancer l’application
Si le serveur Vite tourne déjà, vous voyez les changements en direct. Sinon :
npm run dev
Puis ouvrez l’URL indiquée.
17) Construire la version de production
Quand vous voulez générer une version optimisée (pour déployer sur un hébergeur statique) :
npm run build
Vite crée un dossier dist/.
Pour prévisualiser localement la version de production :
npm run preview
18) Pistes d’amélioration (pour aller plus loin)
Voici des idées simples pour continuer à apprendre :
-
Édition d’une tâche
Ajouter un mode “édition” : double-clic sur le texte, input inline, validation avec Entrée. -
Bouton “Tout terminer / tout réactiver”
Ajouter une action globale qui metdoneàtruepour toutes les tâches (ou l’inverse). -
Bouton “Supprimer les terminées”
Pratique pour apprendrefilteret les actions de masse. -
Validation plus stricte
Empêcher les doublons, limiter la longueur, afficher un message d’erreur. -
Meilleure génération d’identifiants
Si votre navigateur le supporte, vous pouvez remplacercreateId()par :crypto.randomUUID()Cela donne des identifiants uniques robustes.
-
Séparer en composants
Par exemple :TodoFormTodoListTodoItemFilters
Cela rend le code plus lisible et réutilisable.
Conclusion
Vous avez créé une application To-Do complète pour débutants avec React, incluant :
- Un formulaire contrôlé (
input+useState) - Ajout, suppression, bascule terminé/non terminé
- Rendu conditionnel (messages “liste vide”)
- Filtres avec calcul dérivé (
useMemo) - Persistance avec
localStorage(useEffect) - Un peu de CSS pour une interface agréable
Si vous voulez, je peux proposer une version “découpée en composants” (structure de fichiers, props, événements) ou une version avec édition des tâches.