← Retour aux tutoriels

Créer une application To-Do avec React : guide pour développeurs intermédiaires

reactto-dohooksusestateuseeffectlocalstoragejavascriptfrontendtutorieldéveloppement web

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 :


Prérequis

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 :

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 :

useReducer apporte :


3) Mise en place des dossiers

Créez une structure simple :

mkdir -p src/components src/state src/utils

Nous allons créer :


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


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


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é


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


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 :

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


8) Vérification rapide

Lancez le serveur :

npm run dev

Testez :


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 :

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) :

9.4) Réduction des re-rendus

Quand la liste devient grande, chaque action peut re-rendre beaucoup de composants. Quelques pistes :

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 :

  1. Ajout : “ Acheter du lait ” devient “Acheter du lait”
  2. Ajout vide : “ ” ne crée rien
  3. Édition : double-clic, modifier, Enter → titre mis à jour
  4. Annulation : double-clic, modifier, Escape → titre restauré
  5. Blur : double-clic, modifier, clic ailleurs → validation
  6. Filtre : actives/terminées cohérent avec toggle
  7. Recherche : “caf” trouve “Café”
  8. 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

Dans ce cas, useReducer reste pertinent, mais vous ajouterez des actions du type :

12.2) Routage

Ajouter des routes :

Avec une bibliothèque de routage, le filtre peut venir de l’URL, ce qui rend l’application partageable.

12.3) Stockage plus robuste


13) Construction et déploiement

Build de production

npm run build
npm run preview

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 :

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.