Cours JavaScript et React · Module 9: Hooks React modernes

useDeferredValue - valeurs différées pour les performances

17 min de lecture
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}
45

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

Le 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}
78

La 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]);
36

Le 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}
178

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

Articles utiles