Cours JavaScript et React · Module 9: Hooks React modernes
useDeferredValue - valeurs différées pour les performances
Dans cette leçon3
Dans la leçon sur useTransition, c'est toi qui décidais quel changement d'état était moins urgent. Mais que faire si la valeur vient des props, d'une bibliothèque ou du hook de quelqu'un d'autre, et que tu n'as pas accès à son setter ? Le radar du vaisseau donne une nouvelle position toutes les fractions de seconde, et la lourde carte de la galaxie n'arrive pas à suivre.
Le hook useDeferredValue complète useTransition : il te permet de « différer » une valeur afin que React puisse traiter les mises à jour comme moins urgentes. Il est particulièrement utile lorsque tu veux garder l'UI réactive tout en rendant des composants lourds.
Les bases de useDeferredValue
1. Syntaxe et utilisation de base
Tu passes n'importe quelle valeur au hook, et il te renvoie une copie « en retard ». Lors d'un changement, React rend d'abord l'écran avec l'ancienne copie, puis fait le nouveau rendu en arrière-plan et peut l'interrompre. Dans un champ de recherche, cela donne :
1import { useDeferredValue, useState } from 'react';
2
3function SearchApp() {
4 const [query, setQuery] = useState('');
5 const deferredQuery = useDeferredValue(query);
6
7 return (
8 <div>
9 <input
10 value={query}
11 onChange={(e) => setQuery(e.target.value)}
12 />
13
14 {/* L'input se met à jour immédiatement */}
15 <p>Requête actuelle : {query}</p>
16
17 {/* La liste utilise la valeur différée */}
18 <SearchResults query={deferredQuery} />
19 </div>
20 );
21}
22
23function SearchResults({ query }) {
24 // Calculs lourds avec la valeur différée
25 const results = performHeavySearch(query);
26
27 return (
28 <div>
29 <p>Résultats pour : « {query} »</p>
30 <ul>
31 {results.map(result => (
32 <li key={result.id}>{result.title}</li>
33 ))}
34 </ul>
35 </div>
36 );
37}
38
39function performHeavySearch(query) {
40 // Simulation de calculs lourds
41 const items = [];
42 for (let i = 0; i < 5000; i++) {
43 if (`Item ${i}`.toLowerCase().includes(query.toLowerCase())) {
44 items.push({ id: i, title: `Item ${i}` });
45 }
46 }
47 return items;
48}Le champ et le texte « Requête actuelle » lisent la query fraîche, tandis que la liste reçoit deferredQuery. Il y a pourtant un piège, signalé par react.dev : cette optimisation ne fonctionne que si le composant lourd est enveloppé dans memo. Ici, SearchResults n'a pas de memo, donc pendant le rendu urgent il recalcule quand même les résultats, simplement pour l'ancienne valeur. On corrigera cela dans la section 3.
2. Différence entre useDeferredValue et useTransition
Comparons deux façons d'obtenir le même effet. Dans la première, on diffère une valeur, dans la seconde, on sépare nous-mêmes l'état urgent et l'état transitionnel :
1import { useDeferredValue, useTransition, useState } from 'react';
2
3// Approche 1 : useDeferredValue
4function DeferredValueExample() {
5 const [input, setInput] = useState('');
6 const deferredInput = useDeferredValue(input);
7
8 return (
9 <div>
10 <h3>useDeferredValue</h3>
11 <input
12 value={input}
13 onChange={(e) => setInput(e.target.value)}
14 />
15 <HeavyComponent text={deferredInput} />
16 </div>
17 );
18}
19
20// Approche 2 : useTransition
21function TransitionExample() {
22 const [input, setInput] = useState('');
23 const [deferredInput, setDeferredInput] = useState('');
24 const [isPending, startTransition] = useTransition();
25
26 const handleChange = (value) => {
27 setInput(value); // Mise à jour immédiate
28 startTransition(() => {
29 setDeferredInput(value); // Mise à jour différée
30 });
31 };
32
33 return (
34 <div>
35 <h3>useTransition</h3>
36 <input
37 value={input}
38 onChange={(e) => handleChange(e.target.value)}
39 />
40 {isPending && <div>Mise à jour...</div>}
41 <HeavyComponent text={deferredInput} />
42 </div>
43 );
44}
45Les deux composants se comportent de façon similaire, mais useTransition exige l'accès à l'endroit où l'état est mis à jour et te donne un drapeau isPending. useDeferredValue, lui, n'a besoin que de la valeur. Voici le composant lourd commun et les deux versions côte à côte :
1function HeavyComponent({ text }) {
2 // Simulation d'un rendu lourd
3 const items = [];
4 for (let i = 0; i < 1000; i++) {
5 items.push(`${text} - élément ${i}`);
6 }
7
8 return (
9 <div style={{ height: '200px', overflow: 'auto' }}>
10 {items.map((item, index) => (
11 <div key={index} style={{ padding: '2px' }}>
12 {item}
13 </div>
14 ))}
15 </div>
16 );
17}
18
19// Comparaison des deux approches
20function ComparisonDemo() {
21 return (
22 <div style={{ display: 'flex', gap: '20px' }}>
23 <div style={{ flex: 1 }}>
24 <DeferredValueExample />
25 </div>
26 <div style={{ flex: 1 }}>
27 <TransitionExample />
28 </div>
29 </div>
30 );
31}Ma recommandation : si tu as accès au setter, choisis généralement useTransition. Si tu reçois une valeur toute faite depuis les props ou un hook externe, prends useDeferredValue.
3. Travailler avec memo pour l'optimisation
Voici maintenant une version où le report fonctionne vraiment. La liste est enveloppée dans memo, donc React saute son rendu tant que ses props ne changent pas :
1import { useDeferredValue, useState, useMemo, memo } from 'react';
2
3// Composant optimisé avec memo
4const OptimizedList = memo(function OptimizedList({ items, highlightTerm }) {
5 console.log('OptimizedList se rend pour :', highlightTerm);
6
7 return (
8 <div style={{ height: '300px', overflow: 'auto', border: '1px solid #ddd' }}>
9 {items.map(item => (
10 <div
11 key={item.id}
12 style={{
13 padding: '8px',
14 borderBottom: '1px solid #eee',
15 backgroundColor: item.id % 2 === 0 ? '#f9f9f9' : '#fff'
16 }}
17 >
18 <strong>
19 {highlightText(item.title, highlightTerm)}
20 </strong>
21 <div style={{ fontSize: '14px', color: '#666' }}>
22 {highlightText(item.description, highlightTerm)}
23 </div>
24 </div>
25 ))}
26 </div>
27 );
28});
29Le message console.log te permet de vérifier quand la liste se rend réellement. La seconde partie est le composant de recherche avec ses données et un drapeau de « péremption » :
1function SearchWithOptimization() {
2 const [searchTerm, setSearchTerm] = useState('');
3 const deferredSearchTerm = useDeferredValue(searchTerm);
4
5 // Génération des données
6 const allItems = useMemo(() => generateSearchData(2000), []);
7
8 // Filtrage avec la valeur différée
9 const filteredItems = useMemo(() => allItems.filter(item =>
10 item.title.toLowerCase().includes(deferredSearchTerm.toLowerCase()) ||
11 item.description.toLowerCase().includes(deferredSearchTerm.toLowerCase())
12 ), [allItems, deferredSearchTerm]);
13
14 const isStale = searchTerm !== deferredSearchTerm;
15
16 return (
17 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
18 <h2>Recherche avec useDeferredValue</h2>
19
20 <input
21 value={searchTerm}
22 onChange={(e) => setSearchTerm(e.target.value)}
23 style={{
24 width: '100%',
25 padding: '12px',
26 fontSize: '16px',
27 border: '2px solid #ddd',
28 borderRadius: '8px',
29 marginBottom: '10px'
30 }}
31 />
32
33 <div style={{
34 display: 'flex',
35 justifyContent: 'space-between',
36 alignItems: 'center',
37 marginBottom: '10px',
38 fontSize: '14px'
39 }}>
40 <span>
41 {filteredItems.length} résultats trouvés
42 {deferredSearchTerm && ` pour « ${deferredSearchTerm} »`}
43 </span>
44
45 {isStale && (
46 <span style={{ color: '#007bff', fontStyle: 'italic' }}>
47 Mise à jour...
48 </span>
49 )}
50 </div>
51
52 <div style={{ opacity: isStale ? 0.7 : 1, transition: 'opacity 0.2s' }}>
53 <OptimizedList
54 items={filteredItems}
55 highlightTerm={deferredSearchTerm}
56 />
57 </div>
58 </div>
59 );
60}
61
62function generateSearchData(count) {
63 const categories = ['Électronique', 'Livres', 'Vêtements', 'Maison', 'Sport'];
64 const adjectives = ['Moderne', 'Classique', 'Premium', 'Économique', 'Universel'];
65
66 return Array.from({ length: count }, (_, i) => ({
67 id: i,
68 title: `Produit ${adjectives[i % adjectives.length]} ${i + 1}`,
69 description: `Description du produit de la catégorie ${categories[i % categories.length]}. Article de haute qualité.`,
70 category: categories[i % categories.length],
71 price: Math.floor(Math.random() * 1000) + 50
72 }));
73}
74
75function highlightText(text, term) {
76 if (!term) return text;
77
78 const regex = new RegExp(`(${term})`, 'gi');
79 const parts = text.split(regex);
80
81 return parts.map((part, index) =>
82 part.toLowerCase() === term.toLowerCase() ? (
83 <mark key={index} style={{ backgroundColor: '#ffeb3b', padding: '2px' }}>
84 {part}
85 </mark>
86 ) : part
87 );
88}La comparaison searchTerm !== deferredSearchTerm indique que l'écran montre encore d'anciens résultats, donc on assombrit la liste. On garde les données et le filtrage dans useMemo. Sans cela, chaque rendu créerait un nouveau tableau filteredItems, memo verrait toujours de nouvelles props et ne sauterait rien. Depuis React 19, le hook accepte aussi un second argument, useDeferredValue(value, initialValue), la valeur utilisée lors du premier rendu.
Patterns avancés avec useDeferredValue
1. Chargement adaptatif avec fallback
Une valeur différée aide aussi lors du changement d'onglet. Un clic met aussitôt le nouvel onglet en surbrillance, et le contenu le rattrape un instant plus tard :
1import { useDeferredValue, useState, Suspense } from 'react';
2
3function AdaptiveLoadingDemo() {
4 const [selectedTab, setSelectedTab] = useState('popular');
5 const deferredTab = useDeferredValue(selectedTab);
6
7 const tabs = [
8 { id: 'popular', label: 'Populaires' },
9 { id: 'newest', label: 'Récents' },
10 { id: 'trending', label: 'Tendances' }
11 ];
12
13 return (
14 <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
15 <h2>Chargement adaptatif avec useDeferredValue</h2>
16
17 {/* Navigation par onglets */}
18 <div style={{
19 display: 'flex',
20 marginBottom: '20px',
21 borderBottom: '1px solid #ddd'
22 }}>
23 {tabs.map(tab => (
24 <button
25 key={tab.id}
26 onClick={() => setSelectedTab(tab.id)}
27 style={{
28 padding: '12px 20px',
29 border: 'none',
30 backgroundColor: selectedTab === tab.id ? '#007bff' : 'transparent',
31 color: selectedTab === tab.id ? 'white' : '#007bff',
32 cursor: 'pointer',
33 borderRadius: '8px 8px 0 0',
34 marginRight: '5px',
35 fontSize: '16px'
36 }}
37 >
38 {tab.label}
39 </button>
40 ))}
41 </div>
42
43 {/* Contenu avec chargement différé */}
44 <div style={{ position: 'relative' }}>
45 {/* Affiche l'état de chargement pendant le changement d'onglet */}
46 {selectedTab !== deferredTab && (
47 <div style={{
48 position: 'absolute',
49 top: 0,
50 left: 0,
51 right: 0,
52 bottom: 0,
53 backgroundColor: 'rgba(255, 255, 255, 0.8)',
54 display: 'flex',
55 alignItems: 'center',
56 justifyContent: 'center',
57 zIndex: 1,
58 borderRadius: '8px'
59 }}>
60 <div style={{
61 padding: '20px',
62 backgroundColor: 'white',
63 borderRadius: '8px',
64 boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
65 }}>
66 Chargement du contenu...
67 </div>
68 </div>
69 )}
70
71 <Suspense fallback={<TabContentSkeleton />}>
72 <TabContent tab={deferredTab} />
73 </Suspense>
74 </div>
75 </div>
76 );
77}
78La superposition « Chargement du contenu... » apparaît quand selectedTab et deferredTab diffèrent. Voici ensuite le composant de contenu, le squelette et les données :
1function TabContent({ tab }) {
2 // Simulation d'un chargement de données lourd
3 const content = generateTabContent(tab);
4
5 return (
6 <div style={{
7 minHeight: '400px',
8 padding: '20px',
9 backgroundColor: '#f8f9fa',
10 borderRadius: '8px'
11 }}>
12 <h3>Contenu de l'onglet : {tab}</h3>
13 <div style={{ display: 'grid', gap: '15px' }}>
14 {content.map(item => (
15 <div
16 key={item.id}
17 style={{
18 padding: '15px',
19 backgroundColor: 'white',
20 borderRadius: '6px',
21 boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
22 }}
23 >
24 <h4 style={{ margin: '0 0 8px 0', color: '#333' }}>
25 {item.title}
26 </h4>
27 <p style={{ margin: 0, color: '#666', fontSize: '14px' }}>
28 {item.description}
29 </p>
30 <div style={{
31 marginTop: '8px',
32 fontSize: '12px',
33 color: '#999'
34 }}>
35 {item.date} • {item.views} vues
36 </div>
37 </div>
38 ))}
39 </div>
40 </div>
41 );
42}
43
44function TabContentSkeleton() {
45 return (
46 <div style={{
47 minHeight: '400px',
48 padding: '20px',
49 backgroundColor: '#f8f9fa',
50 borderRadius: '8px'
51 }}>
52 <div style={{
53 height: '24px',
54 backgroundColor: '#e9ecef',
55 borderRadius: '4px',
56 marginBottom: '20px',
57 width: '200px'
58 }} />
59
60 {Array.from({ length: 5 }).map((_, i) => (
61 <div
62 key={i}
63 style={{
64 padding: '15px',
65 backgroundColor: 'white',
66 borderRadius: '6px',
67 marginBottom: '15px',
68 boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
69 }}
70 >
71 <div style={{
72 height: '18px',
73 backgroundColor: '#e9ecef',
74 borderRadius: '4px',
75 marginBottom: '8px',
76 width: '70%'
77 }} />
78 <div style={{
79 height: '14px',
80 backgroundColor: '#f1f3f4',
81 borderRadius: '4px',
82 marginBottom: '4px'
83 }} />
84 <div style={{
85 height: '14px',
86 backgroundColor: '#f1f3f4',
87 borderRadius: '4px',
88 width: '60%'
89 }} />
90 </div>
91 ))}
92 </div>
93 );
94}
95
96function generateTabContent(tab) {
97 const contentMap = {
98 popular: [
99 { id: 1, title: 'Article le plus populaire', description: 'Cet article a été le plus populaire cette semaine.', date: '2024-01-15', views: 15420 },
100 { id: 2, title: 'Deuxième du classement', description: 'Un excellent contenu qui a gagné la reconnaissance des lecteurs.', date: '2024-01-14', views: 12380 },
101 { id: 3, title: 'Top 3 de popularité', description: 'Également un article très intéressant avec une excellente note.', date: '2024-01-13', views: 9560 }
102 ],
103 newest: [
104 { id: 4, title: 'Dernier article', description: 'Contenu de blog fraîchement publié.', date: '2024-01-16', views: 245 },
105 { id: 5, title: 'Article d’hier', description: 'Publié hier, gagne déjà en popularité.', date: '2024-01-15', views: 1230 },
106 { id: 6, title: 'D’il y a 2 jours', description: 'Un contenu toujours pertinent et de valeur.', date: '2024-01-14', views: 2100 }
107 ],
108 trending: [
109 { id: 7, title: 'Viral sur les réseaux sociaux', description: 'Cet article fait sensation sur les réseaux sociaux !', date: '2024-01-12', views: 25600 },
110 { id: 8, title: 'Popularité en hausse', description: 'Gagne rapidement en popularité.', date: '2024-01-11', views: 8970 },
111 { id: 9, title: 'Tendance de la semaine', description: 'La principale tendance de la semaine dans notre secteur.', date: '2024-01-10', views: 14520 }
112 ]
113 };
114
115 return contentMap[tab] || [];
116}Remarque que TabContent est ici synchrone, donc tu ne verras le fallback TabContentSkeleton que si le composant se suspend vraiment, par exemple en lisant des données via Suspense. Avec des données chargées ainsi, useDeferredValue continue d'afficher l'ancien contenu au lieu du fallback.
2. Plusieurs filtres à la fois
Plusieurs filtres à la fois sont un bon candidat pour plusieurs valeurs différées. D'abord l'état, les copies différées et le filtrage mémoïsé :
1import { useDeferredValue, useState, useMemo } from 'react';
2
3function SmartFilteringDemo() {
4 const [searchQuery, setSearchQuery] = useState('');
5 const [priceRange, setPriceRange] = useState([0, 1000]);
6 const [category, setCategory] = useState('all');
7
8 // Valeurs différées pour les opérations lourdes
9 const deferredQuery = useDeferredValue(searchQuery);
10 const deferredPriceRange = useDeferredValue(priceRange);
11 const deferredCategory = useDeferredValue(category);
12
13 // Vérifie si un filtrage est en cours
14 const isFiltering =
15 searchQuery !== deferredQuery ||
16 priceRange !== deferredPriceRange ||
17 category !== deferredCategory;
18
19 const products = useMemo(() => generateProducts(1000), []);
20
21 // Filtrage avec les valeurs différées
22 const filteredProducts = useMemo(() => {
23 return products.filter(product => {
24 const matchesSearch = product.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
25 product.description.toLowerCase().includes(deferredQuery.toLowerCase());
26
27 const matchesPrice = product.price >= deferredPriceRange[0] &&
28 product.price <= deferredPriceRange[1];
29
30 const matchesCategory = deferredCategory === 'all' ||
31 product.category === deferredCategory;
32
33 return matchesSearch && matchesPrice && matchesCategory;
34 });
35 }, [products, deferredQuery, deferredPriceRange, deferredCategory]);
36Le drapeau isFiltering compare chaque paire de valeurs. Le tableau priceRange est comparé par référence, ce qui fonctionne, car chaque mouvement du curseur crée un nouveau tableau. Voici le panneau de filtres et la grille des résultats :
1 return (
2 <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
3 <h2>Filtrage intelligent avec useDeferredValue</h2>
4
5 <div style={{
6 display: 'grid',
7 gridTemplateColumns: '300px 1fr',
8 gap: '20px'
9 }}>
10 {/* Barre latérale des filtres */}
11 <div style={{
12 padding: '20px',
13 backgroundColor: '#f8f9fa',
14 borderRadius: '8px',
15 height: 'fit-content'
16 }}>
17 <h3 style={{ marginTop: 0 }}>Filtres</h3>
18
19 {/* Filtre de recherche */}
20 <div style={{ marginBottom: '20px' }}>
21 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
22 Recherche :
23 </label>
24 <input
25 value={searchQuery}
26 onChange={(e) => setSearchQuery(e.target.value)}
27 style={{
28 width: '100%',
29 padding: '8px',
30 border: '1px solid #ddd',
31 borderRadius: '4px'
32 }}
33 />
34 </div>
35
36 {/* Filtre par catégorie */}
37 <div style={{ marginBottom: '20px' }}>
38 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
39 Catégorie :
40 </label>
41 <select
42 value={category}
43 onChange={(e) => setCategory(e.target.value)}
44 style={{
45 width: '100%',
46 padding: '8px',
47 border: '1px solid #ddd',
48 borderRadius: '4px'
49 }}
50 >
51 <option value="all">Toutes</option>
52 <option value="Electronics">Électronique</option>
53 <option value="Books">Livres</option>
54 <option value="Clothing">Vêtements</option>
55 <option value="Home">Maison</option>
56 </select>
57 </div>
58
59 {/* Filtre par plage de prix */}
60 <div style={{ marginBottom: '20px' }}>
61 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
62 Prix : {priceRange[0]} - {priceRange[1]} $
63 </label>
64 <input
65 type="range"
66 min="0"
67 max="1000"
68 value={priceRange[1]}
69 onChange={(e) => setPriceRange([priceRange[0], parseInt(e.target.value)])}
70 style={{ width: '100%' }}
71 />
72 </div>
73
74 {/* État du filtre */}
75 <div style={{
76 padding: '10px',
77 backgroundColor: isFiltering ? '#fff3cd' : '#d4edda',
78 borderRadius: '4px',
79 fontSize: '14px',
80 textAlign: 'center'
81 }}>
82 {isFiltering ? (
83 <span>Filtrage...</span>
84 ) : (
85 <span>Terminé</span>
86 )}
87 </div>
88 </div>
89
90 {/* Résultats */}
91 <div>
92 <div style={{
93 display: 'flex',
94 justifyContent: 'space-between',
95 alignItems: 'center',
96 marginBottom: '20px'
97 }}>
98 <h3 style={{ margin: 0 }}>
99 Produits ({filteredProducts.length})
100 </h3>
101
102 {isFiltering && (
103 <span style={{ color: '#007bff', fontSize: '14px' }}>
104 Mise à jour des résultats...
105 </span>
106 )}
107 </div>
108
109 <div style={{
110 opacity: isFiltering ? 0.7 : 1,
111 transition: 'opacity 0.3s',
112 display: 'grid',
113 gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
114 gap: '15px'
115 }}>
116 {filteredProducts.slice(0, 20).map(product => (
117 <div
118 key={product.id}
119 style={{
120 padding: '15px',
121 backgroundColor: 'white',
122 border: '1px solid #e9ecef',
123 borderRadius: '8px',
124 boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
125 }}
126 >
127 <h4 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
128 {highlightText(product.name, deferredQuery)}
129 </h4>
130 <p style={{
131 margin: '0 0 8px 0',
132 fontSize: '14px',
133 color: '#666',
134 lineHeight: '1.4'
135 }}>
136 {highlightText(product.description, deferredQuery)}
137 </p>
138 <div style={{
139 display: 'flex',
140 justifyContent: 'space-between',
141 alignItems: 'center',
142 fontSize: '14px'
143 }}>
144 <span style={{
145 backgroundColor: '#e9ecef',
146 padding: '2px 6px',
147 borderRadius: '4px',
148 fontSize: '12px'
149 }}>
150 {product.category}
151 </span>
152 <span style={{
153 fontWeight: 'bold',
154 color: '#007bff',
155 fontSize: '16px'
156 }}>
157 ${product.price}
158 </span>
159 </div>
160 </div>
161 ))}
162 </div>
163
164 {filteredProducts.length === 0 && (
165 <div style={{
166 textAlign: 'center',
167 padding: '40px',
168 color: '#666'
169 }}>
170 Aucun produit ne correspond aux critères
171 </div>
172 )}
173 </div>
174 </div>
175 </div>
176 );
177}
178Les contrôles lisent les valeurs fraîches, tandis que la grille de produits utilise les valeurs différées. Enfin, le générateur de données et la mise en surbrillance :
1function generateProducts(count) {
2 const categories = ['Électronique', 'Livres', 'Vêtements', 'Maison'];
3 const adjectives = ['Premium', 'Classique', 'Moderne', 'Vintage', 'Professionnel'];
4 const nouns = ['Appareil', 'Outil', 'Article', 'Produit', 'Gadget'];
5
6 return Array.from({ length: count }, (_, i) => ({
7 id: i,
8 name: `${nouns[i % nouns.length]} ${adjectives[i % adjectives.length]} ${i + 1}`,
9 description: `Article ${categories[i % categories.length].toLowerCase()} de haute qualité, doté d'excellentes caractéristiques et d'une grande durabilité.`,
10 category: categories[i % categories.length],
11 price: Math.floor(Math.random() * 950) + 50
12 }));
13}
14
15function highlightText(text, term) {
16 if (!term) return text;
17
18 const regex = new RegExp(`(${term})`, 'gi');
19 const parts = text.split(regex);
20
21 return parts.map((part, index) =>
22 part.toLowerCase() === term.toLowerCase() ? (
23 <mark key={index} style={{ backgroundColor: '#ffeb3b', padding: '1px' }}>
24 {part}
25 </mark>
26 ) : part
27 );
28}Contrairement au debouncing, il n'y a pas ici de délai fixe : React ne diffère le rendu qu'aussi longtemps que l'appareil en a besoin.
Bonnes pratiques avec useDeferredValue
1. Quand utiliser useDeferredValue
Les exemples ci-dessous montrent où le report est rentable et où il ne fait qu'ajouter du travail. La clé, c'est le coût de rendu du composant qui reçoit la valeur différée :
1// BON - Listes lourdes et filtrage
2function ProductSearch() {
3 const [query, setQuery] = useState('');
4 const deferredQuery = useDeferredValue(query);
5
6 return (
7 <>
8 <input value={query} onChange={e => setQuery(e.target.value)} />
9 <HeavyProductList searchTerm={deferredQuery} />
10 </>
11 );
12}
13
14// BON - Rendu de graphiques
15function ChartDashboard() {
16 const [dateRange, setDateRange] = useState([startDate, endDate]);
17 const deferredDateRange = useDeferredValue(dateRange);
18
19 return (
20 <>
21 <DateRangePicker onChange={setDateRange} />
22 <ExpensiveChart dateRange={deferredDateRange} />
23 </>
24 );
25}
26
27// MAUVAIS - Opérations simples
28function SimpleCounter() {
29 const [count, setCount] = useState(0);
30 const deferredCount = useDeferredValue(count); // Inutile !
31
32 return <div>{deferredCount}</div>;
33}Le compteur de SimpleCounter affiche un seul nombre, donc le différer n'accélère rien et ajoute juste un second rendu.
2. Combiner avec d'autres hooks
Dans le tableau de données, on combine useDeferredValue avec useMemo et useCallback. Le filtrage et le tri ne dépendent que des valeurs différées :
1import { useDeferredValue, useState, useMemo, useCallback } from 'react';
2
3function OptimizedDataTable() {
4 const [searchTerm, setSearchTerm] = useState('');
5 const [sortConfig, setSortConfig] = useState({ field: 'name', direction: 'asc' });
6
7 const deferredSearchTerm = useDeferredValue(searchTerm);
8 const deferredSortConfig = useDeferredValue(sortConfig);
9
10 const data = useMemo(() => generateLargeDataset(5000), []);
11
12 // Filtrage et tri mémoïsés
13 const processedData = useMemo(() => {
14 let filtered = data;
15
16 if (deferredSearchTerm) {
17 filtered = data.filter(item =>
18 item.name.toLowerCase().includes(deferredSearchTerm.toLowerCase())
19 );
20 }
21
22 return [...filtered].sort((a, b) => {
23 const direction = deferredSortConfig.direction === 'asc' ? 1 : -1;
24 return String(a[deferredSortConfig.field])
25 .localeCompare(String(b[deferredSortConfig.field]), undefined, { numeric: true }) * direction;
26 });
27 }, [data, deferredSearchTerm, deferredSortConfig]);
28
29 const handleSort = useCallback((field) => {
30 setSortConfig(prev => ({
31 field,
32 direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc'
33 }));
34 }, []);
35
36 const isStale = searchTerm !== deferredSearchTerm || sortConfig !== deferredSortConfig;
37
38 return (
39 <div>
40 <input
41 value={searchTerm}
42 onChange={(e) => setSearchTerm(e.target.value)}
43 />
44
45 <table style={{ opacity: isStale ? 0.7 : 1 }}>
46 <thead>
47 <tr>
48 <th onClick={() => handleSort('name')}>Nom</th>
49 <th onClick={() => handleSort('category')}>Catégorie</th>
50 <th onClick={() => handleSort('price')}>Prix</th>
51 </tr>
52 </thead>
53 <tbody>
54 {processedData.slice(0, 50).map(item => (
55 <tr key={item.id}>
56 <td>{item.name}</td>
57 <td>{item.category}</td>
58 <td>{item.price}</td>
59 </tr>
60 ))}
61 </tbody>
62 </table>
63 </div>
64 );
65}On trie une copie, [...filtered], car sort modifie le tableau sur place et, sans copie, abîmerait les data mémoïsées. La comparaison via String avec l'option numeric gère aussi la colonne du prix, qui contient des nombres.
3. Ce qu'il faut éviter
Le dernier exemple montre un excès de bonne volonté. Trois valeurs différées séparées pour un formulaire simple ne font que compliquer le code :
1// MAUVAIS - Surutilisation
2function OverusedDeferred() {
3 const [name, setName] = useState('');
4 const [age, setAge] = useState(0);
5 const [email, setEmail] = useState('');
6
7 // Toutes les valeurs n'ont pas besoin d'être différées !
8 const deferredName = useDeferredValue(name);
9 const deferredAge = useDeferredValue(age);
10 const deferredEmail = useDeferredValue(email);
11
12 return <SimpleForm name={deferredName} age={deferredAge} email={deferredEmail} />;
13}
14
15// MIEUX - Utilisation sélective
16function BetterApproach() {
17 const [formData, setFormData] = useState({ name: '', age: 0, email: '' });
18 const deferredFormData = useDeferredValue(formData);
19
20 return <HeavyValidationForm data={deferredFormData} />;
21}Mieux vaut différer un seul objet passé à un composant vraiment lourd.
useDeferredValue est une manière élégante d'améliorer les performances des applications React sans gestion d'état complexe. Il te permet de garder les éléments critiques de l'UI réactifs tout en effectuant des opérations lourdes en arrière-plan.
Mesure avant d'optimiser : tu découvriras la virtualisation des longues listes dans la prochaine leçon. Retiens : une valeur différée est un écho radar que la carte de la galaxie lit dès que le pilote a terminé sa manœuvre.
Code de cette leçon : App.jsx
1import { useDeferredValue, useState, memo, useMemo } from 'react';
2
3// Simulation d'une opération lourde - rendu de nombreux éléments
4function generateItems(count, filter = '') {
5 const items = [];
6 for (let i = 0; i < count; i++) {
7 const name = `Produit ${i + 1}`;
8 const category = ['Electronics', 'Books', 'Clothing', 'Home', 'Sports'][i % 5];
9
10 if (filter === '' ||
11 name.toLowerCase().includes(filter.toLowerCase()) ||
12 category.toLowerCase().includes(filter.toLowerCase())) {
13 items.push({
14 id: i,
15 name,
16 category,
17 price: Math.floor(Math.random() * 1000) + 10,
18 rating: (Math.random() * 5).toFixed(1),
19 inStock: Math.random() > 0.2
20 });
21 }
22 }
23 return items;
24}
25
26// Composant optimisé avec memo
27const ProductList = memo(function ProductList({ items, isStale }) {
28 // Simulation de calculs lourds
29 const processedItems = useMemo(() => {
30 return items.map(item => ({
31 ...item,
32 displayPrice: `${item.price} crédits`,
33 ratingStars: '◆'.repeat(Math.floor(item.rating)) + '◇'.repeat(5 - Math.floor(item.rating))
34 }));
35 }, [items]);
36
37 return (
38 <div style={{
39 opacity: isStale ? 0.6 : 1,
40 transition: 'opacity 0.2s',
41 height: '500px',
42 overflow: 'auto',
43 border: '1px solid #ddd',
44 borderRadius: '8px',
45 backgroundColor: '#fff'
46 }}>
47 {isStale && (
48 <div style={{
49 position: 'sticky',
50 top: 0,
51 backgroundColor: '#fff3cd',
52 padding: '8px 16px',
53 borderBottom: '1px solid #ffeaa7',
54 fontSize: '14px',
55 color: '#856404'
56 }}>
57 Mise à jour des résultats...
58 </div>
59 )}
60
61 {processedItems.length === 0 ? (
62 <div style={{
63 padding: '40px',
64 textAlign: 'center',
65 color: '#666'
66 }}>
67 Aucun produit ne correspond aux critères
68 </div>
69 ) : (
70 <div style={{ padding: '16px' }}>
71 <div style={{
72 marginBottom: '16px',
73 fontSize: '14px',
74 color: '#666',
75 borderBottom: '1px solid #eee',
76 paddingBottom: '8px'
77 }}>
78 {processedItems.length} produits trouvés
79 </div>
80
81 <div style={{
82 display: 'grid',
83 gap: '16px',
84 gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
85 }}>
86 {processedItems.map(item => (
87 <div
88 key={item.id}
89 style={{
90 padding: '16px',
91 border: '1px solid #e9ecef',
92 borderRadius: '8px',
93 backgroundColor: '#f8f9fa'
94 }}
95 >
96 <div style={{
97 display: 'flex',
98 justifyContent: 'space-between',
99 alignItems: 'flex-start',
100 marginBottom: '8px'
101 }}>
102 <h3 style={{
103 margin: 0,
104 fontSize: '16px',
105 color: '#333'
106 }}>
107 {item.name}
108 </h3>
109 <span style={{
110 fontSize: '12px',
111 padding: '4px 8px',
112 backgroundColor: '#007bff',
113 color: 'white',
114 borderRadius: '12px'
115 }}>
116 {item.category}
117 </span>
118 </div>
119
120 <div style={{
121 display: 'flex',
122 justifyContent: 'space-between',
123 alignItems: 'center',
124 marginBottom: '8px'
125 }}>
126 <span style={{
127 fontSize: '18px',
128 fontWeight: 'bold',
129 color: '#28a745'
130 }}>
131 {item.displayPrice}
132 </span>
133
134 <div style={{ fontSize: '14px', color: '#ffc107' }}>
135 {item.ratingStars} ({item.rating})
136 </div>
137 </div>
138
139 <div style={{
140 fontSize: '12px',
141 color: item.inStock ? '#28a745' : '#dc3545',
142 fontWeight: 'bold'
143 }}>
144 {item.inStock ? 'En stock' : 'Rupture de stock'}
145 </div>
146 </div>
147 ))}
148 </div>
149 </div>
150 )}
151 </div>
152 );
153});
154
155function DeferredValueDemo() {
156 const [query, setQuery] = useState('');
157 const [category, setCategory] = useState('');
158 const [sortBy, setSortBy] = useState('name');
159
160 // VALEURS DIFFÉRÉES - se mettront à jour avec un délai
161 const deferredQuery = useDeferredValue(query);
162 const deferredCategory = useDeferredValue(category);
163 const deferredSortBy = useDeferredValue(sortBy);
164
165 // Vérifie si les valeurs sont obsolètes (stale)
166 const isStale = deferredQuery !== query ||
167 deferredCategory !== category ||
168 deferredSortBy !== sortBy;
169
170 // Génération des résultats à partir des valeurs DIFFÉRÉES
171 const results = useMemo(() => {
172 let items = generateItems(8000, deferredQuery);
173
174 // Filtre par catégorie
175 if (deferredCategory) {
176 items = items.filter(item =>
177 item.category.toLowerCase().includes(deferredCategory.toLowerCase())
178 );
179 }
180
181 // Tri
182 items.sort((a, b) => {
183 switch (deferredSortBy) {
184 case 'price':
185 return a.price - b.price;
186 case 'rating':
187 return b.rating - a.rating;
188 case 'name':
189 default:
190 return a.name.localeCompare(b.name);
191 }
192 });
193
194 return items;
195 }, [deferredQuery, deferredCategory, deferredSortBy]);
196
197 return (
198 <div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
199 <h2>Démo useDeferredValue - Boutique en ligne</h2>
200
201 <div style={{
202 display: 'flex',
203 gap: '16px',
204 marginBottom: '20px',
205 flexWrap: 'wrap'
206 }}>
207 <div style={{ flex: '1', minWidth: '200px' }}>
208 <label style={{
209 display: 'block',
210 marginBottom: '4px',
211 fontSize: '14px',
212 fontWeight: 'bold'
213 }}>
214 Rechercher un produit :
215 </label>
216 <input
217 value={query}
218 onChange={(e) => setQuery(e.target.value)}
219 placeholder="Saisis le nom du produit..."
220 style={{
221 width: '100%',
222 padding: '10px',
223 fontSize: '16px',
224 border: '2px solid #ddd',
225 borderRadius: '6px',
226 outline: 'none',
227 boxSizing: 'border-box'
228 }}
229 />
230 </div>
231
232 <div style={{ flex: '1', minWidth: '150px' }}>
233 <label style={{
234 display: 'block',
235 marginBottom: '4px',
236 fontSize: '14px',
237 fontWeight: 'bold'
238 }}>
239 Catégorie :
240 </label>
241 <select
242 value={category}
243 onChange={(e) => setCategory(e.target.value)}
244 style={{
245 width: '100%',
246 padding: '10px',
247 fontSize: '16px',
248 border: '2px solid #ddd',
249 borderRadius: '6px',
250 outline: 'none',
251 backgroundColor: 'white'
252 }}
253 >
254 <option value="">Toutes les catégories</option>
255 <option value="Électronique">Électronique</option>
256 <option value="Livres">Livres</option>
257 <option value="Vêtements">Vêtements</option>
258 <option value="Maison">Maison</option>
259 <option value="Sport">Sport</option>
260 </select>
261 </div>
262
263 <div style={{ flex: '1', minWidth: '150px' }}>
264 <label style={{
265 display: 'block',
266 marginBottom: '4px',
267 fontSize: '14px',
268 fontWeight: 'bold'
269 }}>
270 Trier par :
271 </label>
272 <select
273 value={sortBy}
274 onChange={(e) => setSortBy(e.target.value)}
275 style={{
276 width: '100%',
277 padding: '10px',
278 fontSize: '16px',
279 border: '2px solid #ddd',
280 borderRadius: '6px',
281 outline: 'none',
282 backgroundColor: 'white'
283 }}
284 >
285 <option value="name">Nom</option>
286 <option value="price">Prix</option>
287 <option value="rating">Note</option>
288 </select>
289 </div>
290 </div>
291
292 <div style={{
293 display: 'flex',
294 justifyContent: 'space-between',
295 alignItems: 'center',
296 marginBottom: '16px',
297 padding: '12px',
298 backgroundColor: '#e3f2fd',
299 borderRadius: '8px',
300 fontSize: '14px'
301 }}>
302 <div>
303 <strong>État des filtres :</strong>
304 <ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
305 <li>Recherche : « {query} » {deferredQuery !== query && '(mise à jour...)'}</li>
306 <li>Catégorie : {category || 'Toutes'} {deferredCategory !== category && '(mise à jour...)'}</li>
307 <li>Tri : {sortBy} {deferredSortBy !== sortBy && '(mise à jour...)'}</li>
308 </ul>
309 </div>
310
311 <div style={{ textAlign: 'right' }}>
312 {isStale ? (
313 <span style={{ color: '#ff9800', fontWeight: 'bold' }}>
314 Mise à jour des résultats...
315 </span>
316 ) : (
317 <span style={{ color: '#4caf50', fontWeight: 'bold' }}>
318 Résultats à jour
319 </span>
320 )}
321 </div>
322 </div>
323
324 <ProductList items={results} isStale={isStale} />
325
326 <div style={{
327 marginTop: '20px',
328 padding: '16px',
329 backgroundColor: '#f1f8e9',
330 borderRadius: '8px',
331 fontSize: '14px'
332 }}>
333 <strong>Ce qu'il faut observer dans cette démo :</strong>
334 <ul style={{ margin: '10px 0', paddingLeft: '20px' }}>
335 <li><strong>Inputs réactifs :</strong> tous les contrôles réagissent immédiatement</li>
336 <li><strong>Rendu différé :</strong> la liste de produits se met à jour avec un délai</li>
337 <li><strong>Indicateur stale :</strong> visible quand les données sont obsolètes</li>
338 <li><strong>Transparence :</strong> la liste devient transparente pendant les mises à jour</li>
339 <li><strong>Optimisation memo :</strong> le composant ne se re-rend pas quand les props sont identiques</li>
340 </ul>
341
342 <div style={{
343 marginTop: '12px',
344 padding: '8px',
345 backgroundColor: '#fff3e0',
346 borderRadius: '4px'
347 }}>
348 <strong>Essaie :</strong> change rapidement les filtres et observe comment React privilégie
349 la réactivité de l'input par rapport au rendu de la liste de produits !
350 </div>
351 </div>
352 </div>
353 );
354}
355
356function ComparisonDemo() {
357 const [query, setQuery] = useState('');
358 const deferredQuery = useDeferredValue(query);
359
360 // Simulation de différentes approches de gestion
361 const [version, setVersion] = useState('deferred');
362
363 const results = useMemo(() => {
364 const searchTerm = version === 'deferred' ? deferredQuery : query;
365 return generateItems(5000, searchTerm);
366 }, [version === 'deferred' ? deferredQuery : query, version]);
367
368 return (
369 <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
370 <h2>Comparaison : useDeferredValue vs sans optimisation</h2>
371
372 <div style={{
373 display: 'flex',
374 gap: '10px',
375 marginBottom: '20px',
376 padding: '16px',
377 backgroundColor: '#f8f9fa',
378 borderRadius: '8px'
379 }}>
380 <button
381 onClick={() => setVersion('deferred')}
382 style={{
383 padding: '10px 20px',
384 backgroundColor: version === 'deferred' ? '#28a745' : '#6c757d',
385 color: 'white',
386 border: 'none',
387 borderRadius: '4px',
388 cursor: 'pointer'
389 }}
390 >
391 Avec useDeferredValue
392 </button>
393 <button
394 onClick={() => setVersion('immediate')}
395 style={{
396 padding: '10px 20px',
397 backgroundColor: version === 'immediate' ? '#dc3545' : '#6c757d',
398 color: 'white',
399 border: 'none',
400 borderRadius: '4px',
401 cursor: 'pointer'
402 }}
403 >
404 Sans optimisation
405 </button>
406 </div>
407
408 <div style={{ marginBottom: '20px' }}>
409 <label style={{
410 display: 'block',
411 marginBottom: '8px',
412 fontSize: '16px',
413 fontWeight: 'bold'
414 }}>
415 Rechercher des produits :
416 </label>
417 <input
418 value={query}
419 onChange={(e) => setQuery(e.target.value)}
420 placeholder="Essaie de taper vite et observe la différence..."
421 style={{
422 width: '100%',
423 padding: '12px',
424 fontSize: '16px',
425 border: '2px solid #ddd',
426 borderRadius: '8px',
427 outline: 'none',
428 boxSizing: 'border-box'
429 }}
430 />
431
432 <div style={{
433 marginTop: '8px',
434 fontSize: '14px',
435 color: '#666',
436 display: 'flex',
437 justifyContent: 'space-between'
438 }}>
439 <span>Valeur actuelle : « {query} »</span>
440 <span>
441 {version === 'deferred' && deferredQuery !== query && (
442 <span style={{ color: '#ff9800' }}>
443 Rendu : « {deferredQuery} »
444 </span>
445 )}
446 </span>
447 </div>
448 </div>
449
450 <div style={{
451 height: '400px',
452 overflow: 'auto',
453 border: '1px solid #ddd',
454 borderRadius: '8px',
455 backgroundColor: '#fff'
456 }}>
457 {results.length === 0 ? (
458 <div style={{
459 padding: '40px',
460 textAlign: 'center',
461 color: '#666'
462 }}>
463 Aucun résultat
464 </div>
465 ) : (
466 results.slice(0, 100).map(item => (
467 <div
468 key={item.id}
469 style={{
470 padding: '12px',
471 borderBottom: '1px solid #eee',
472 display: 'flex',
473 justifyContent: 'space-between',
474 alignItems: 'center'
475 }}
476 >
477 <div>
478 <div style={{ fontWeight: 'bold' }}>{item.name}</div>
479 <div style={{ fontSize: '12px', color: '#666' }}>
480 {item.category}
481 </div>
482 </div>
483 <div style={{ fontWeight: 'bold', color: '#28a745' }}>
484 {item.price} crédits
485 </div>
486 </div>
487 ))
488 )}
489 </div>
490
491 <div style={{
492 marginTop: '16px',
493 padding: '12px',
494 backgroundColor: version === 'deferred' ? '#d4edda' : '#f8d7da',
495 borderRadius: '8px',
496 fontSize: '14px'
497 }}>
498 <strong>
499 {version === 'deferred' ? 'Avec useDeferredValue :' : 'Sans optimisation :'}
500 </strong>
501 <p style={{ margin: '8px 0' }}>
502 {version === 'deferred'
503 ? "L'input reste réactif, la liste se met à jour avec un délai quand on tape vite."
504 : "Chaque changement de l'input rend immédiatement toute la liste, ce qui peut provoquer des ralentissements."
505 }
506 </p>
507 </div>
508 </div>
509 );
510}
511
512export default function App() {
513 const [activeDemo, setActiveDemo] = useState('shop');
514
515 return (
516 <div style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
517 <nav style={{
518 backgroundColor: '#fff',
519 padding: '10px 0',
520 borderBottom: '1px solid #ddd',
521 marginBottom: '20px'
522 }}>
523 <div style={{
524 maxWidth: '1200px',
525 margin: '0 auto',
526 display: 'flex',
527 gap: '10px',
528 padding: '0 20px'
529 }}>
530 <button
531 onClick={() => setActiveDemo('shop')}
532 style={{
533 padding: '10px 20px',
534 backgroundColor: activeDemo === 'shop' ? '#007bff' : '#f8f9fa',
535 color: activeDemo === 'shop' ? 'white' : '#333',
536 border: '1px solid #ddd',
537 borderRadius: '4px',
538 cursor: 'pointer'
539 }}
540 >
541 Boutique en ligne
542 </button>
543 <button
544 onClick={() => setActiveDemo('comparison')}
545 style={{
546 padding: '10px 20px',
547 backgroundColor: activeDemo === 'comparison' ? '#007bff' : '#f8f9fa',
548 color: activeDemo === 'comparison' ? 'white' : '#333',
549 border: '1px solid #ddd',
550 borderRadius: '4px',
551 cursor: 'pointer'
552 }}
553 >
554 Comparaison
555 </button>
556 </div>
557 </nav>
558
559 {activeDemo === 'shop' ? <DeferredValueDemo /> : <ComparisonDemo />}
560 </div>
561 );
562}Tu as repéré une erreur dans cette leçon ?
Tâches pratiques dans le jeu
- Tri horizontal
Reconstitue la syntaxe du hook useDeferredValue :
- Tri horizontal
Reconstitue la syntaxe de déstructuration du hook useTransition :