Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram

Debouncing et optimisation des performances

7 min de lecture
Dans cette leçon8

Le capitaine tape "Jack Sparrow" dans la recherche, et l'appli envoie douze requêtes au serveur, une par caractère, espace compris. Le serveur cherche des résultats pour "J", "Ja" et "Jac" que personne ne veut voir, et les réponses peuvent arriver dans le désordre. Dans cette leçon, tu découvres le debouncing et quelques moyens d'éviter à React du travail inutile.

Une recherche sans debounce

Voici la version qui interroge le serveur à chaque lettre :

1function SearchPirates() {
2  const [query, setQuery] = useState("");
3  const [results, setResults] = useState<Pirate[]>([]);
4
5  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
6    const value = e.target.value;
7    setQuery(value);
8
9    // Appel API à CHAQUE frappe !
10    fetchPirates(value).then(setResults);
11  };
12
13  return <input value={query} onChange={handleChange} />;
14}

Chaque appel à handleChange lance aussitôt fetchPirates. Le champ fonctionne bien, le seul problème est le nombre de requêtes.

Le debouncing

Le debouncing consiste à retarder l'exécution d'une fonction jusqu'à ce que l'utilisateur arrête de taper. Comme un barreur qui ne tourne pas la barre à chaque rafale, mais attend que le vent se stabilise :

1import { useState, useEffect } from 'react';
2
3function SearchPirates() {
4  const [query, setQuery] = useState("");
5  const [debouncedQuery, setDebouncedQuery] = useState("");
6  const [results, setResults] = useState<Pirate[]>([]);
7
8  // Debounce - attend 500ms après le dernier changement
9  useEffect(() => {
10    const timer = setTimeout(() => {
11      setDebouncedQuery(query);
12    }, 500);
13
14    return () => clearTimeout(timer);
15  }, [query]);
16
17  // Fetch uniquement quand debouncedQuery change
18  useEffect(() => {
19    if (debouncedQuery) {
20      fetchPirates(debouncedQuery).then(setResults);
21    }
22  }, [debouncedQuery]);
23
24  return (
25    <div>
26      <input
27        value={query}
28        onChange={(e) => setQuery(e.target.value)}
29        placeholder="Rechercher des pirates..."
30      />
31      {results.map(pirate => <div key={pirate.id}>{pirate.name}</div>)}
32    </div>
33  );
34}

Le premier useEffect tient en quatre lignes : const timer = setTimeout(() => {, dedans setDebouncedQuery(query), puis }, 500) et enfin return () => clearTimeout(timer). Chaque lettre change query, donc React nettoie d'abord l'effet précédent, en annulant l'ancien minuteur, puis en lance un nouveau. Seules 500 ms de silence font passer la valeur dans debouncedQuery, et le second effet envoie une seule requête au lieu de douze. Le champ réagit toujours à chaque caractère, seule la requête est retardée.

Un hook useDebounce sur mesure

On enferme la même logique dans un hook réutilisable :

1function useDebounce<T>(value: T, delay: number): T {
2  const [debouncedValue, setDebouncedValue] = useState<T>(value);
3
4  useEffect(() => {
5    const timer = setTimeout(() => {
6      setDebouncedValue(value);
7    }, delay);
8
9    return () => clearTimeout(timer);
10  }, [value, delay]);
11
12  return debouncedValue;
13}
14
15// Utilisation - super simple !
16function SearchPirates() {
17  const [query, setQuery] = useState("");
18  const debouncedQuery = useDebounce(query, 500);
19  const [results, setResults] = useState<Pirate[]>([]);
20
21  useEffect(() => {
22    if (debouncedQuery) {
23      fetchPirates(debouncedQuery).then(setResults);
24    }
25  }, [debouncedQuery]);
26
27  return (
28    <input
29      value={query}
30      onChange={(e) => setQuery(e.target.value)}
31      placeholder="Rechercher des pirates..."
32    />
33  );
34}

<T> est un type générique : le hook marche pour du texte, un nombre ou un objet, et renvoie une valeur du même type. Le composant n'appelle plus que useDebounce(query, 500).

Le throttling

Le throttling exécute une fonction au plus une fois par période donnée, quel que soit le nombre d'appels :

DebounceThrottle
Quand ça s'exécuteaprès X ms de silencetoutes les X ms
Pour quoirecherche, saisiedéfilement, mouvement de souris
1function useThrottle<T>(value: T, limit: number): T {
2  const [throttledValue, setThrottledValue] = useState<T>(value);
3  const lastRan = useRef(Date.now());
4
5  useEffect(() => {
6    const handler = setTimeout(() => {
7      if (Date.now() - lastRan.current >= limit) {
8        setThrottledValue(value);
9        lastRan.current = Date.now();
10      }
11    }, limit - (Date.now() - lastRan.current));
12
13    return () => clearTimeout(handler);
14  }, [value, limit]);
15
16  return throttledValue;
17}
18
19// Utilisation - position du scroll
20function ScrollTracker() {
21  const [scrollY, setScrollY] = useState(0);
22  const throttledScrollY = useThrottle(scrollY, 200);
23
24  useEffect(() => {
25    const handleScroll = () => setScrollY(window.scrollY);
26    window.addEventListener('scroll', handleScroll);
27    return () => window.removeEventListener('scroll', handleScroll);
28  }, []);
29
30  return <div>Scroll : {throttledScrollY}px</div>;
31}

L'événement scroll arrive des dizaines de fois par seconde, et useThrottle(scrollY, 200) laisse passer une nouvelle valeur au plus toutes les 200 ms. L'écouteur scroll ne change pas, on limite seulement la fréquence de rafraîchissement de l'affichage.

React.memo

Quand un parent se rend, tous ses enfants se rendent aussi par défaut :

1function PostCard({ post }: { post: Post }) {
2  console.log("Rendu de PostCard :", post.id);
3  return <div>{post.content}</div>;
4}
5
6// Le parent se re-rend → TOUS les PostCards se re-rendent !

React.memo enveloppe un composant et évite de le rendre à nouveau si ses props n'ont pas changé :

1import { memo } from 'react';
2
3const PostCard = memo(function PostCard({ post }: { post: Post }) {
4  console.log("Rendu de PostCard :", post.id);
5  return <div>{post.content}</div>;
6});
7
8// Le parent se re-rend → PostCard se re-rend UNIQUEMENT si post a changé !

React compare les props de façon superficielle : il vérifie qu'il s'agit des mêmes valeurs et des mêmes objets, pas de leur contenu. L'état propre du composant déclenche toujours un rendu.

useMemo

useMemo met en cache le résultat d'un calcul coûteux :

1function PirateStats({ pirates }: { pirates: Pirate[] }) {
2  // Sans memo - calcule à CHAQUE rendu
3  const totalLevel = pirates.reduce((sum, p) => sum + p.level, 0);
4
5  // Avec memo - calcule uniquement quand pirates change
6  const totalLevel = useMemo(() => {
7    console.log("Calcul du niveau total...");
8    return pirates.reduce((sum, p) => sum + p.level, 0);
9  }, [pirates]);
10
11  return <div>Niveau total : {totalLevel}</div>;
12}

Les deux versions figurent côte à côte dans le bloc uniquement pour comparer ; dans un vrai composant, tu n'en gardes qu'une, car déclarer deux fois const totalLevel est une erreur. La version avec useMemo ne recalcule la somme que si le tableau pirates change. Pour additionner deux nombres, c'est exagéré, pour filtrer de grandes listes, non.

useCallback

useMemo met en cache un résultat (une valeur), et useCallback met en cache la fonction elle-même. Le problème apparaît quand une fonction est passée à un composant enveloppé par memo :

1function Parent() {
2  const [count, setCount] = useState(0);
3
4  // Nouvelle fonction à chaque rendu !
5  const handleClick = () => console.log("Cliqué");
6
7  return <MemoizedChild onClick={handleClick} />;
8  // Child se re-rend malgré memo, car handleClick est toujours « nouveau »
9}

Chaque rendu crée une nouvelle fonction handleClick, donc memo voit une nouvelle prop et rend l'enfant. La solution :

1import { useCallback } from 'react';
2
3function Parent() {
4  const [count, setCount] = useState(0);
5
6  // Même fonction entre les rendus
7  const handleClick = useCallback(() => {
8    console.log("Cliqué");
9  }, []);  // Tableau vide - la fonction ne change jamais
10
11  return <MemoizedChild onClick={handleClick} />;
12  // Child ne se re-rend PAS (handleClick reste identique)
13}

Un tableau de dépendances vide signifie que la fonction est créée une fois, et tu récupères la même aux rendus suivants. Si la fonction utilise l'état, ajoute-le aux dépendances, sinon elle lira une valeur périmée.

Les clés de liste

La dernière optimisation concerne la prop key :

1// MAUVAIS - utiliser l'index
2{pirates.map((pirate, index) => (
3  <PirateCard key={index} pirate={pirate} />
4))}
5
6// BON - utiliser un ID unique
7{pirates.map((pirate) => (
8  <PirateCard key={pirate.id} pirate={pirate} />
9))}

Quand tu supprimes un élément d'une liste indexée par index, les index des éléments suivants changent. React croit que ce sont d'autres éléments, fait des re-rendus inutiles et peut même mélanger le texte saisi dans les champs. Un id unique reste attaché à son élément pour de bon.

Conseil du Capitaine Barbe-Rouge : n'optimise pas par avance. Mesure d'abord ce qui ralentit vraiment, par exemple dans l'onglet Profiler des React DevTools. Dans les projets avec React Compiler (dans Next.js, l'option reactCompiler), la plupart de la mémoïsation se fait automatiquement, mais tu croiseras memo, useMemo et useCallback partout dans le code des autres.

À retenir : le debounce attend le silence, le throttle garde la cadence, et la mémoïsation évite un travail dont le résultat n'a pas changé. L'éditeur ci-dessous contient le système de likes et de commentaires de la leçon sur l'état, où les commentaires s'affichent avec key={comment.id} : ajoutes-en quelques-uns et vois que chacun a sa propre clé. Ensuite, fais les exercices.

Code de cette leçon : App.jsx
1// Système de likes avec commentaires
2import { useState } from 'react';
3
4export default function App() {
5  const [likes, setLikes] = useState(42);
6  const [isLiked, setIsLiked] = useState(false);
7  const [comments, setComments] = useState([
8    { id: 1, author: 'Morgan', text: 'Trésor incroyable !' },
9    { id: 2, author: 'Polly', text: 'Où l\'as-tu trouvé ?' },
10  ]);
11  const [newComment, setNewComment] = useState('');
12
13  const handleLike = () => {
14    if (isLiked) {
15      setLikes(likes - 1);
16    } else {
17      setLikes(likes + 1);
18    }
19    setIsLiked(!isLiked);
20  };
21
22  const handleAddComment = (e) => {
23    e.preventDefault();
24    if (newComment.trim()) {
25      setComments([
26        ...comments,
27        {
28          id: Date.now(),
29          author: 'You',
30          text: newComment,
31        },
32      ]);
33      setNewComment('');
34    }
35  };
36
37  return (
38    <div className="min-h-screen bg-gray-100 p-8 flex items-center justify-center">
39      <div className="bg-white rounded-lg shadow-xl max-w-2xl w-full overflow-hidden">
40        {/* Header */}
41        <div className="flex items-center gap-3 p-4 border-b">
42          <div className="text-3xl">◆</div>
43          <div>
44            <h3 className="font-bold">Captain Redbeard</h3>
45            <p className="text-sm text-gray-500">il y a 2 heures</p>
46          </div>
47        </div>
48
49        {/* Image */}
50        <div className="aspect-square bg-gradient-to-br from-yellow-500 to-amber-700 flex items-center justify-center">
51          <div className="text-9xl">◆</div>
52        </div>
53
54        {/* Actions */}
55        <div className="p-4">
56          <div className="flex items-center gap-4 mb-3">
57            <button
58              onClick={handleLike}
59              className={`text-sm font-semibold transition-transform hover:scale-110 ${
60                isLiked ? 'text-red-500' : 'text-gray-700'
61              }`}
62            >
63              {isLiked ? 'Aimé' : 'J’aime'}
64            </button>
65            <button className="text-sm font-semibold hover:scale-110 transition">Commenter</button>
66            <button className="text-sm font-semibold hover:scale-110 transition">Partager</button>
67          </div>
68
69          <p className="font-bold mb-2">{likes} likes</p>
70          <p className="mb-4">
71            <span className="font-bold">Captain Redbeard</span> J'ai trouvé ce
72            diamant dans un coffre englouti !
73          </p>
74
75          {/* Commentaires */}
76          <div className="space-y-2 mb-4">
77            {comments.map((comment) => (
78              <div key={comment.id} className="text-sm">
79                <span className="font-bold">{comment.author}</span>{' '}
80                {comment.text}
81              </div>
82            ))}
83          </div>
84
85          {/* Ajouter un commentaire */}
86          <form onSubmit={handleAddComment} className="flex items-center gap-2 border-t pt-3">
87            <input
88              type="text"
89              value={newComment}
90              onChange={(e) => setNewComment(e.target.value)}
91              placeholder="Ajoute un commentaire..."
92              className="flex-1 outline-none"
93            />
94            <button
95              type="submit"
96              disabled={!newComment.trim()}
97              className="text-blue-500 font-bold disabled:text-blue-300"
98            >
99              Envoyer
100            </button>
101          </form>
102        </div>
103      </div>
104    </div>
105  );
106}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce que le debouncing dans un champ de recherche ?

  2. 2. En quoi useMemo diffère-t-il de useCallback ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les lignes d'un useEffect qui applique un debounce à la valeur query (de haut en bas) :

Articles utiles