Créer une application To-Do avec React : guide pour développeurs intermédiaires
Ce tutoriel explique comment construire une application To-Do moderne avec React, en visant un niveau intermédiaire : architecture de composants, gestion d’état, persistance, accessibilité, bonnes pratiques, et une base solide pour évoluer vers une application plus complexe.
Objectifs et fonctionnalités
À la fin, vous aurez une application capable de :
- Ajouter une tâche (titre + options simples)
- Marquer une tâche comme terminée / non terminée
- Éditer une tâche
- Supprimer une tâche
- Filtrer (toutes / actives / terminées)
- Rechercher par texte
- Persister les données dans
localStorage - Gérer proprement l’état et les effets avec des hooks (
useReducer,useEffect,useMemo) - Respecter des bases d’accessibilité (labels, focus, navigation clavier)
Prérequis
- Node.js (version récente recommandée)
- Connaissances de base de React (JSX, props, hooks)
- Connaissances JavaScript ES2020+ (destructuring, modules, etc.)
Vérifiez votre environnement :
node -v
npm -v
1) Initialisation du projet
Nous allons utiliser Vite (rapide, moderne, standard de facto pour démarrer un projet React).
Création du projet
npm create vite@latest todo-react-intermediaire -- --template react
cd todo-react-intermediaire
npm install
npm run dev
Ouvrez ensuite l’URL indiquée (souvent http://localhost:5173).
Nettoyage minimal
Dans src/App.jsx, remplacez le contenu par un squelette simple :
export default function App() {
return (
<main style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
<h1>To-Do</h1>
</main>
);
}
2) Conception : modèle de données et architecture
Avant de coder, définissons une structure claire.
Modèle d’une tâche
Une tâche doit contenir :
id: identifiant unique (chaîne)title: texte de la tâchecompleted: booléencreatedAt: timestamp (utile pour trier)updatedAt: timestamp (utile pour audit / synchronisation)
Exemple :
{
id: "t_123",
title: "Acheter du café",
completed: false,
createdAt: 1700000000000,
updatedAt: 1700000000000
}
Pourquoi useReducer plutôt que useState ?
Pour une To-Do simple, useState suffit. Mais dès qu’on ajoute :
- plusieurs actions (ajout, suppression, toggle, édition),
- des invariants (pas de titre vide, normalisation),
- de la persistance,
- des optimisations,
useReducer apporte :
- une logique centralisée,
- des transitions d’état explicites,
- une meilleure testabilité (réducteur pur),
- moins de “setState en cascade”.
3) Mise en place des dossiers
Créez une structure simple :
mkdir -p src/components src/state src/utils
Nous allons créer :
src/state/todosReducer.js: réducteur + actionssrc/utils/storage.js: lecture/écriturelocalStoragesrc/components/TodoForm.jsx: formulaire d’ajoutsrc/components/TodoList.jsx: listesrc/components/TodoItem.jsx: itemsrc/components/Filters.jsx: filtres + recherche
4) Persistance : utilitaires localStorage
Créez src/utils/storage.js :
const STORAGE_KEY = "todos_v1";
export function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
}
export function saveTodos(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
Explications
- On protège
JSON.parseavec untry/catch: un stockage corrompu ne doit pas casser l’application. - On versionne la clé (
todos_v1) : si le schéma change, on peut migrer verstodos_v2.
5) Réducteur : actions et invariants
Créez src/state/todosReducer.js :
function now() {
return Date.now();
}
function normalizeTitle(title) {
return title.trim().replace(/\s+/g, " ");
}
export function createTodo(title) {
const t = normalizeTitle(title);
const timestamp = now();
return {
id: crypto.randomUUID(),
title: t,
completed: false,
createdAt: timestamp,
updatedAt: timestamp,
};
}
export const initialState = {
todos: [],
filter: "all", // "all" | "active" | "completed"
query: "",
};
export function todosReducer(state, action) {
switch (action.type) {
case "INIT_FROM_STORAGE": {
return {
...state,
todos: action.payload ?? [],
};
}
case "ADD_TODO": {
const title = normalizeTitle(action.payload ?? "");
if (!title) return state;
const todo = createTodo(title);
return {
...state,
todos: [todo, ...state.todos],
};
}
case "TOGGLE_TODO": {
const id = action.payload;
return {
...state,
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed, updatedAt: now() } : t
),
};
}
case "DELETE_TODO": {
const id = action.payload;
return {
...state,
todos: state.todos.filter((t) => t.id !== id),
};
}
case "EDIT_TODO": {
const { id, title } = action.payload || {};
const nextTitle = normalizeTitle(title ?? "");
if (!id || !nextTitle) return state;
return {
...state,
todos: state.todos.map((t) =>
t.id === id ? { ...t, title: nextTitle, updatedAt: now() } : t
),
};
}
case "SET_FILTER": {
return { ...state, filter: action.payload };
}
case "SET_QUERY": {
return { ...state, query: action.payload ?? "" };
}
case "CLEAR_COMPLETED": {
return {
...state,
todos: state.todos.filter((t) => !t.completed),
};
}
default:
return state;
}
}
Points importants
- Normalisation :
trim+ réduction des espaces multiples. Cela évite les doublons visuels et les titres “vides”. - Invariants : on refuse un
ADD_TODOsi le titre est vide. - Réducteur pur : aucune écriture
localStorageici. La persistance se fait via unuseEffectdans le composant racine. - UUID :
crypto.randomUUID()est supporté par la plupart des navigateurs modernes. Si vous ciblez des environnements plus anciens, il faudra un polyfill.
6) Composants UI
6.1) Formulaire d’ajout
Créez src/components/TodoForm.jsx :
import { useId, useState } from "react";
export default function TodoForm({ onAdd }) {
const [title, setTitle] = useState("");
const inputId = useId();
function handleSubmit(e) {
e.preventDefault();
onAdd(title);
setTitle("");
}
return (
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8 }}>
<label htmlFor={inputId} style={{ position: "absolute", left: -9999 }}>
Nouvelle tâche
</label>
<input
id={inputId}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ajouter une tâche…"
style={{ flex: 1, padding: 10 }}
autoComplete="off"
/>
<button type="submit" style={{ padding: "10px 14px" }}>
Ajouter
</button>
</form>
);
}
Accessibilité
- Le
labelest présent (important pour lecteurs d’écran) mais visuellement caché. useIdévite les collisions d’identifiants, utile si le composant est rendu plusieurs fois.
6.2) Filtres et recherche
Créez src/components/Filters.jsx :
import { useId } from "react";
export default function Filters({
filter,
query,
onFilterChange,
onQueryChange,
onClearCompleted,
stats,
}) {
const searchId = useId();
return (
<section style={{ display: "grid", gap: 12, marginTop: 16 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => onFilterChange("all")}
aria-pressed={filter === "all"}
>
Toutes ({stats.total})
</button>
<button
type="button"
onClick={() => onFilterChange("active")}
aria-pressed={filter === "active"}
>
Actives ({stats.active})
</button>
<button
type="button"
onClick={() => onFilterChange("completed")}
aria-pressed={filter === "completed"}
>
Terminées ({stats.completed})
</button>
<span style={{ flex: 1 }} />
<button type="button" onClick={onClearCompleted} disabled={stats.completed === 0}>
Supprimer les terminées
</button>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<label htmlFor={searchId} style={{ minWidth: 90 }}>
Recherche
</label>
<input
id={searchId}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Filtrer par texte…"
style={{ flex: 1, padding: 10 }}
/>
</div>
</section>
);
}
Détails
aria-pressedindique l’état “toggle” des boutons de filtre.- Les statistiques (
stats) évitent de recalculer partout et rendent l’UI plus informative. - Le bouton “Supprimer les terminées” est désactivé si aucune tâche terminée.
6.3) Item de liste : affichage + édition
Créez src/components/TodoItem.jsx :
import { useEffect, useId, useRef, useState } from "react";
export default function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const checkboxId = useId();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.title);
const inputRef = useRef(null);
useEffect(() => {
if (isEditing) {
setDraft(todo.title);
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing, todo.title]);
function submitEdit() {
const next = draft;
onEdit(todo.id, next);
setIsEditing(false);
}
function cancelEdit() {
setDraft(todo.title);
setIsEditing(false);
}
return (
<li
style={{
display: "grid",
gridTemplateColumns: "auto 1fr auto",
gap: 10,
alignItems: "center",
padding: "10px 8px",
borderBottom: "1px solid #eee",
}}
>
<input
id={checkboxId}
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{!isEditing ? (
<label
htmlFor={checkboxId}
style={{
cursor: "pointer",
textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? "#666" : "inherit",
}}
onDoubleClick={() => setIsEditing(true)}
title="Double-cliquez pour éditer"
>
{todo.title}
</label>
) : (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitEdit();
if (e.key === "Escape") cancelEdit();
}}
onBlur={submitEdit}
style={{ padding: 8 }}
aria-label="Éditer la tâche"
/>
)}
<div style={{ display: "flex", gap: 8 }}>
{!isEditing ? (
<button type="button" onClick={() => setIsEditing(true)}>
Éditer
</button>
) : (
<button type="button" onClick={cancelEdit}>
Annuler
</button>
)}
<button type="button" onClick={() => onDelete(todo.id)}>
Supprimer
</button>
</div>
</li>
);
}
Pourquoi gérer un draft local ?
L’édition est un bon exemple de séparation des responsabilités :
- L’état global (
todos) doit rester cohérent et validé. - L’état local (
draft) sert de tampon pendant la saisie. - À la validation (Enter, blur), on déclenche
onEditpour mettre à jour l’état global.
Cela évite de mettre à jour le store global à chaque frappe, ce qui peut être coûteux et complique l’annulation.
6.4) Liste
Créez src/components/TodoList.jsx :
import TodoItem from "./TodoItem.jsx";
export default function TodoList({ todos, onToggle, onDelete, onEdit }) {
if (todos.length === 0) {
return <p style={{ marginTop: 16 }}>Aucune tâche à afficher.</p>;
}
return (
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
{todos.map((t) => (
<TodoItem
key={t.id}
todo={t}
onToggle={onToggle}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</ul>
);
}
7) Assemblage dans App.jsx
Remplacez src/App.jsx par :
import { useEffect, useMemo, useReducer } from "react";
import TodoForm from "./components/TodoForm.jsx";
import TodoList from "./components/TodoList.jsx";
import Filters from "./components/Filters.jsx";
import { initialState, todosReducer } from "./state/todosReducer.js";
import { loadTodos, saveTodos } from "./utils/storage.js";
function computeStats(todos) {
const total = todos.length;
const completed = todos.filter((t) => t.completed).length;
const active = total - completed;
return { total, active, completed };
}
function matchesFilter(todo, filter) {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
}
function matchesQuery(todo, query) {
const q = query.trim().toLowerCase();
if (!q) return true;
return todo.title.toLowerCase().includes(q);
}
export default function App() {
const [state, dispatch] = useReducer(todosReducer, initialState);
// Initialisation depuis localStorage (une seule fois au montage)
useEffect(() => {
const todos = loadTodos();
dispatch({ type: "INIT_FROM_STORAGE", payload: todos });
}, []);
// Persistance : à chaque changement de todos
useEffect(() => {
saveTodos(state.todos);
}, [state.todos]);
const stats = useMemo(() => computeStats(state.todos), [state.todos]);
const visibleTodos = useMemo(() => {
return state.todos
.filter((t) => matchesFilter(t, state.filter))
.filter((t) => matchesQuery(t, state.query));
}, [state.todos, state.filter, state.query]);
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
<header style={{ display: "grid", gap: 6 }}>
<h1 style={{ margin: 0 }}>To-Do</h1>
<p style={{ margin: 0, color: "#555" }}>
Double-cliquez sur une tâche pour l’éditer. Entrée pour valider, Échap pour annuler.
</p>
</header>
<section style={{ marginTop: 16 }}>
<TodoForm onAdd={(title) => dispatch({ type: "ADD_TODO", payload: title })} />
<Filters
filter={state.filter}
query={state.query}
stats={stats}
onFilterChange={(f) => dispatch({ type: "SET_FILTER", payload: f })}
onQueryChange={(q) => dispatch({ type: "SET_QUERY", payload: q })}
onClearCompleted={() => dispatch({ type: "CLEAR_COMPLETED" })}
/>
<TodoList
todos={visibleTodos}
onToggle={(id) => dispatch({ type: "TOGGLE_TODO", payload: id })}
onDelete={(id) => dispatch({ type: "DELETE_TODO", payload: id })}
onEdit={(id, title) => dispatch({ type: "EDIT_TODO", payload: { id, title } })}
/>
</section>
</main>
);
}
Analyse de l’assemblage
- Hydratation :
INIT_FROM_STORAGEau montage. On ne lit paslocalStorageà chaque rendu. - Persistance :
saveTodosdéclenché uniquement quandstate.todoschange. - Dérivés :
statsetvisibleTodossont calculés viauseMemopour éviter des recalculs inutiles (utile quand la liste grossit). - Filtrage + recherche : on applique deux filtres successifs, lisibles et testables.
8) Vérification rapide
Lancez le serveur :
npm run dev
Testez :
- Ajout de tâches (y compris avec espaces au début/fin)
- Toggle terminé
- Édition (double-clic, Enter, Escape, blur)
- Suppression
- Filtres
- Recherche
- Rechargement de la page (les tâches doivent rester)
9) Améliorations intermédiaires (recommandées)
Cette section approfondit des points importants pour un développeur intermédiaire : robustesse, UX, performance, et préparation à l’évolutivité.
9.1) Tri stable et intentionnel
Actuellement, on ajoute en tête ([todo, ...state.todos]). C’est un tri implicite “plus récent d’abord”. Si vous voulez un tri explicite (par date de création, puis titre), faites-le dans visibleTodos :
const visibleTodos = useMemo(() => {
return state.todos
.filter((t) => matchesFilter(t, state.filter))
.filter((t) => matchesQuery(t, state.query))
.toSorted((a, b) => b.createdAt - a.createdAt);
}, [state.todos, state.filter, state.query]);
Remarque : toSorted est récent. Si non supporté, utilisez :
[...arr].sort(...)
9.2) Prévenir les éditions “vides”
Le réducteur ignore EDIT_TODO si le titre est vide. Mais côté UX, on peut informer l’utilisateur. Plusieurs options :
- Afficher un message d’erreur local dans
TodoItem - Empêcher la validation si
draft.trim()est vide - Restaurer automatiquement l’ancien titre (ce que fait déjà le réducteur en refusant la mise à jour)
Une variante dans submitEdit :
function submitEdit() {
if (!draft.trim()) {
cancelEdit();
return;
}
onEdit(todo.id, draft);
setIsEditing(false);
}
9.3) Gestion du focus après ajout
Une UX agréable consiste à remettre le focus sur l’input après ajout. Pour cela, vous pouvez passer une ref au champ dans TodoForm. Exemple (approche simple) :
- Ajouter
const inputRef = useRef(null); - Mettre
ref={inputRef}sur l’input - Après
setTitle(""), faireinputRef.current?.focus()
9.4) Réduction des re-rendus
Quand la liste devient grande, chaque action peut re-rendre beaucoup de composants. Quelques pistes :
React.memosurTodoItemsi les props sont stables- Éviter de recréer des callbacks à chaque rendu (avec
useCallback) - Virtualisation de liste (bibliothèques spécialisées) si milliers d’items
Ici, l’application reste raisonnable, mais l’architecture est compatible avec ces optimisations.
10) Ajout de styles propres (optionnel mais conseillé)
Pour éviter les styles inline et améliorer la maintenabilité, vous pouvez créer src/index.css et y mettre des styles de base.
Exemple minimal :
:root {
color-scheme: light;
}
button {
padding: 8px 10px;
}
button[aria-pressed="true"] {
font-weight: 700;
text-decoration: underline;
}
input {
border: 1px solid #ccc;
border-radius: 6px;
}
button {
border: 1px solid #ccc;
border-radius: 6px;
background: #fff;
}
button:disabled {
opacity: 0.5;
}
Assurez-vous que src/main.jsx importe bien ./index.css (Vite le fait souvent par défaut).
11) Tests manuels et scénarios à couvrir
Même sans framework de test, adoptez des scénarios reproductibles :
- Ajout : “ Acheter du lait ” devient “Acheter du lait”
- Ajout vide : “ ” ne crée rien
- Édition : double-clic, modifier, Enter → titre mis à jour
- Annulation : double-clic, modifier, Escape → titre restauré
- Blur : double-clic, modifier, clic ailleurs → validation
- Filtre : actives/terminées cohérent avec toggle
- Recherche : “caf” trouve “Café”
- Persistance : recharger la page conserve l’état
12) Préparer l’évolution : vers une vraie application
Votre base est saine. Voici des évolutions naturelles :
12.1) Synchronisation serveur
- Remplacer
localStoragepar une API REST/GraphQL - Introduire des états “loading / error”
- Gérer les conflits (édition concurrente)
Dans ce cas, useReducer reste pertinent, mais vous ajouterez des actions du type :
FETCH_TODOS_START / SUCCESS / ERRORSYNC_TODO_START / SUCCESS / ERROR
12.2) Routage
Ajouter des routes :
/: toutes/active/completed
Avec une bibliothèque de routage, le filtre peut venir de l’URL, ce qui rend l’application partageable.
12.3) Stockage plus robuste
IndexedDBpour de gros volumes- Migration de schéma (v1 → v2)
- Chiffrement côté client (selon contexte)
13) Construction et déploiement
Build de production
npm run build
npm run preview
buildgénère des fichiers optimisés dansdist/previewsert le build localement pour validation
Déploiement statique
Comme c’est une SPA sans backend, vous pouvez déployer dist/ sur un hébergement statique.
Récapitulatif
Vous avez construit une application To-Do React intermédiaire avec :
- Une architecture claire (composants + réducteur + utilitaires)
- Un état centralisé via
useReducer - Des dérivations optimisées via
useMemo - Une persistance fiable via
localStorage - Une édition ergonomique et accessible
Si vous souhaitez, je peux proposer une variante avec TypeScript, ou une version avec contexte React (createContext) pour éviter de passer les callbacks en props lorsque l’application grandit.