Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram
Debouncing et optimisation des performances
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 :
| Debounce | Throttle | |
|---|---|---|
| Quand ça s'exécute | après X ms de silence | toutes les X ms |
| Pour quoi | recherche, saisie | dé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. Qu'est-ce que le debouncing dans un champ de recherche ?
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) :