← Retour aux tutoriels

Créer une application To-Do simple avec React (débutants)

reactto-dodébutantsjavascripttutorielfront-endcomposantsstate

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

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 :

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 :

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 :

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
}

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

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))
  );
}

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 :

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));
}

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 :

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

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
  }
}, []);

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.

  1. Importer useRef :
import { useEffect, useMemo, useRef, useState } from "react";
  1. Créer la ref :
const inputRef = useRef(null);
  1. L’attacher à l’input :
<input
  ref={inputRef}
  type="text"
  placeholder="Écrire une tâche..."
  value={text}
  onChange={(e) => setText(e.target.value)}
/>
  1. 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 :

  1. Édition d’une tâche
    Ajouter un mode “édition” : double-clic sur le texte, input inline, validation avec Entrée.

  2. Bouton “Tout terminer / tout réactiver”
    Ajouter une action globale qui met done à true pour toutes les tâches (ou l’inverse).

  3. Bouton “Supprimer les terminées”
    Pratique pour apprendre filter et les actions de masse.

  4. Validation plus stricte
    Empêcher les doublons, limiter la longueur, afficher un message d’erreur.

  5. Meilleure génération d’identifiants
    Si votre navigateur le supporte, vous pouvez remplacer createId() par :

    crypto.randomUUID()

    Cela donne des identifiants uniques robustes.

  6. Séparer en composants
    Par exemple :

    • TodoForm
    • TodoList
    • TodoItem
    • Filters

    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 :

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.