Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
useDeferredValue - opóźnione wartości dla wydajności
W tej lekcji3
W lekcji o useTransition to Ty decydowałeś, która zmiana stanu jest mniej pilna. Co jednak, gdy wartość przychodzi z propsów, z biblioteki albo z cudzego hooka i nie masz dostępu do jej setera? Radar statku podaje nową pozycję co ułamek sekundy, a ciężka mapa galaktyki nie nadąża z rysowaniem.
Hook useDeferredValue to uzupełnienie useTransition, które pozwala na "opóźnienie" wartości, dzięki czemu React może traktować aktualizacje jako mniej pilne. Jest szczególnie przydatny, gdy chcemy zachować responsywność UI podczas renderowania ciężkich komponentów.
Podstawy useDeferredValue
1. Składnia i podstawowe użycie
Przekazujesz do hooka dowolną wartość, a on zwraca jej "spóźnioną" kopię. Przy zmianie React najpierw renderuje ekran ze starą kopią, a nowy render robi w tle i może go przerwać. W wyszukiwarce wygląda to tak:
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 {/* Input aktualizuje się natychmiast */}
15 <p>Aktualne zapytanie: {query}</p>
16
17 {/* Lista używa opóźnionej wartości */}
18 <SearchResults query={deferredQuery} />
19 </div>
20 );
21}
22
23function SearchResults({ query }) {
24 // Ciężkie obliczenia z opóźnioną wartością
25 const results = performHeavySearch(query);
26
27 return (
28 <div>
29 <p>Wyniki dla: "{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 // Symulacja ciężkich obliczeń
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}Pole i napis "Aktualne zapytanie" czytają świeże query, a lista dostaje deferredQuery. Jest jednak haczyk, na który zwraca uwagę react.dev: ta optymalizacja działa tylko wtedy, gdy ciężki komponent jest owinięty w memo. Tutaj SearchResults nie ma memo, więc w pilnym renderze i tak liczy wyniki od nowa, tyle że dla starej wartości. Naprawimy to w sekcji 3.
2. Różnica między useDeferredValue a useTransition
Porównajmy dwa sposoby osiągnięcia tego samego efektu. W pierwszym opóźniamy wartość, w drugim sami rozdzielamy stan na pilny i przejściowy:
1import { useDeferredValue, useTransition, useState } from 'react';
2
3// Podejście 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// Podejście 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); // Natychmiastowa aktualizacja
28 startTransition(() => {
29 setDeferredInput(value); // Opóźniona aktualizacja
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>Aktualizowanie...</div>}
41 <HeavyComponent text={deferredInput} />
42 </div>
43 );
44}
45Oba komponenty dają podobny efekt, ale useTransition wymaga dostępu do miejsca aktualizacji stanu i daje flagę isPending. Z kolei useDeferredValue wystarczy sama wartość. Oto wspólny ciężki komponent i zestawienie obu wersji obok siebie:
1function HeavyComponent({ text }) {
2 // Symulacja ciężkiego renderowania
3 const items = [];
4 for (let i = 0; i < 1000; i++) {
5 items.push(`${text} - element ${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// Porównanie obu podejść
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}Moja rekomendacja: jeśli masz dostęp do setera, zwykle wybierz useTransition. Jeśli dostajesz gotową wartość z props lub zewnętrznego hooka, sięgnij po useDeferredValue.
3. Współpraca z memo dla optymalizacji
Teraz wersja, w której opóźnienie naprawdę działa. Lista jest opakowana w memo, więc React pominie jej render, dopóki propsy się nie zmienią:
1import { useDeferredValue, useState, useMemo, memo } from 'react';
2
3// Komponent zoptymalizowany z memo
4const OptimizedList = memo(function OptimizedList({ items, highlightTerm }) {
5 console.log('OptimizedList renderuje się dla:', 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});
29Komunikat w console.log pozwala sprawdzić, kiedy lista faktycznie się renderuje. Druga część to komponent wyszukiwarki z danymi i flagą "nieaktualności":
1function SearchWithOptimization() {
2 const [searchTerm, setSearchTerm] = useState('');
3 const deferredSearchTerm = useDeferredValue(searchTerm);
4
5 // Generowanie danych
6 const allItems = useMemo(() => generateSearchData(2000), []);
7
8 // Filtrowanie z opóźnioną wartością
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>Wyszukiwarka z 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 Znaleziono {filteredItems.length} wyników
42 {deferredSearchTerm && ` dla "${deferredSearchTerm}"`}
43 </span>
44
45 {isStale && (
46 <span style={{ color: '#007bff', fontStyle: 'italic' }}>
47 Aktualizowanie...
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 = ['Elektronika', 'Książki', 'Odzież', 'Dom', 'Sport'];
64 const adjectives = ['Nowoczesny', 'Klasyczny', 'Premium', 'Ekonomiczny', 'Uniwersalny'];
65
66 return Array.from({ length: count }, (_, i) => ({
67 id: i,
68 title: `${adjectives[i % adjectives.length]} Produkt ${i + 1}`,
69 description: `Opis produktu z kategorii ${categories[i % categories.length]}. Wysokiej jakości artykuł.`,
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}Porównanie searchTerm !== deferredSearchTerm mówi, że ekran pokazuje jeszcze stare wyniki, więc przyciemniamy listę. Dane i filtrowanie trzymamy w useMemo. Bez tego każdy render tworzyłby nową tablicę filteredItems, a memo zawsze widziałby nowe propsy i nic by nie pominął. Od React 19 hook przyjmuje też drugi argument, useDeferredValue(value, initialValue), czyli wartość używaną przy pierwszym renderze.
Zaawansowane wzorce z useDeferredValue
1. Zakładki z opóźnioną treścią
Opóźniona wartość sprawdza się też przy przełączaniu zakładek. Kliknięcie od razu podświetla nową zakładkę, a treść dogania ją chwilę później:
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: 'Popularne' },
9 { id: 'newest', label: 'Najnowsze' },
10 { id: 'trending', label: 'Trending' }
11 ];
12
13 return (
14 <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
15 <h2>Adaptive Loading z useDeferredValue</h2>
16
17 {/* Tab Navigation */}
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 {/* Content with deferred loading */}
44 <div style={{ position: 'relative' }}>
45 {/* Show loading state when tab is changing */}
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 Ładowanie zawartości...
67 </div>
68 </div>
69 )}
70
71 <Suspense fallback={<TabContentSkeleton />}>
72 <TabContent tab={deferredTab} />
73 </Suspense>
74 </div>
75 </div>
76 );
77}
78Nakładka "Ładowanie zawartości..." pojawia się, gdy selectedTab i deferredTab się różnią. Poniżej komponenty treści, szkieletu i dane:
1function TabContent({ tab }) {
2 // Symulacja ciężkiego ładowania danych
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>Zawartość zakładki: {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} wyświetleń
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: 'Najpopularniejszy artykuł', description: 'Ten artykuł cieszył się największą popularnością w tym tygodniu.', date: '2024-01-15', views: 15420 },
100 { id: 2, title: 'Drugi w rankingu', description: 'Świetny materiał, który zdobył uznanie czytelników.', date: '2024-01-14', views: 12380 },
101 { id: 3, title: 'Top 3 popularności', description: 'Również bardzo ciekawy artykuł z wysoką oceną.', date: '2024-01-13', views: 9560 }
102 ],
103 newest: [
104 { id: 4, title: 'Najnowszy post', description: 'Świeżo opublikowany materiał na blogu.', date: '2024-01-16', views: 245 },
105 { id: 5, title: 'Wczorajszy artykuł', description: 'Opublikowany wczoraj, już zyskuje popularność.', date: '2024-01-15', views: 1230 },
106 { id: 6, title: 'Sprzed 2 dni', description: 'Nadal aktualny i wartościowy materiał.', date: '2024-01-14', views: 2100 }
107 ],
108 trending: [
109 { id: 7, title: 'Viral na social media', description: 'Ten artykuł podbija social media!', date: '2024-01-12', views: 25600 },
110 { id: 8, title: 'Rosnąca popularność', description: 'Szybko zyskuje na popularności.', date: '2024-01-11', views: 8970 },
111 { id: 9, title: 'Trend tygodnia', description: 'Główny trend tego tygodnia w naszej branży.', date: '2024-01-10', views: 14520 }
112 ]
113 };
114
115 return contentMap[tab] || [];
116}Zwróć uwagę, że TabContent jest tu synchroniczny, więc fallback TabContentSkeleton zobaczysz dopiero wtedy, gdy komponent naprawdę się zawiesi, np. czytając dane przez Suspense. Przy danych ładowanych w ten sposób useDeferredValue dłużej pokazuje starą treść zamiast fallbacku.
2. Kilka filtrów naraz
Kilka filtrów naraz to dobry kandydat na kilka opóźnionych wartości. Najpierw stan, opóźnione kopie i memoizowane filtrowanie:
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 // Opóźnione wartości dla ciężkich operacji
9 const deferredQuery = useDeferredValue(searchQuery);
10 const deferredPriceRange = useDeferredValue(priceRange);
11 const deferredCategory = useDeferredValue(category);
12
13 // Sprawdzenie czy filtrowanie jest w toku
14 const isFiltering =
15 searchQuery !== deferredQuery ||
16 priceRange !== deferredPriceRange ||
17 category !== deferredCategory;
18
19 const products = useMemo(() => generateProducts(1000), []);
20
21 // Filtrowanie z opóźnionymi wartościami
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]);
36Flaga isFiltering porównuje każdą parę wartości. Tablica priceRange jest porównywana po referencji, co działa, bo każda zmiana suwaka tworzy nową tablicę. Teraz panel filtrów i siatka wyników:
1 return (
2 <div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
3 <h2>Smart Filtering z useDeferredValue</h2>
4
5 <div style={{
6 display: 'grid',
7 gridTemplateColumns: '300px 1fr',
8 gap: '20px'
9 }}>
10 {/* Filters Sidebar */}
11 <div style={{
12 padding: '20px',
13 backgroundColor: '#f8f9fa',
14 borderRadius: '8px',
15 height: 'fit-content'
16 }}>
17 <h3 style={{ marginTop: 0 }}>Filtry</h3>
18
19 {/* Search Filter */}
20 <div style={{ marginBottom: '20px' }}>
21 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
22 Szukaj:
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 {/* Category Filter */}
37 <div style={{ marginBottom: '20px' }}>
38 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
39 Kategoria:
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">Wszystkie</option>
52 <option value="Electronics">Elektronika</option>
53 <option value="Books">Książki</option>
54 <option value="Clothing">Odzież</option>
55 <option value="Home">Dom</option>
56 </select>
57 </div>
58
59 {/* Price Range Filter */}
60 <div style={{ marginBottom: '20px' }}>
61 <label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
62 Cena: {priceRange[0]} - {priceRange[1]} zł
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 {/* Filter Status */}
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>Filtrowanie...</span>
84 ) : (
85 <span>Gotowe</span>
86 )}
87 </div>
88 </div>
89
90 {/* Results */}
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 Produkty ({filteredProducts.length})
100 </h3>
101
102 {isFiltering && (
103 <span style={{ color: '#007bff', fontSize: '14px' }}>
104 Aktualizowanie wyników...
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} zł
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 Brak produktów spełniających kryteria
171 </div>
172 )}
173 </div>
174 </div>
175 </div>
176 );
177}
178Pola sterujące czytają świeże wartości, a siatka produktów korzysta z opóźnionych. Na koniec generator danych i podświetlanie:
1function generateProducts(count) {
2 const categories = ['Electronics', 'Books', 'Clothing', 'Home'];
3 const adjectives = ['Premium', 'Classic', 'Modern', 'Vintage', 'Professional'];
4 const nouns = ['Device', 'Tool', 'Item', 'Product', 'Gadget'];
5
6 return Array.from({ length: count }, (_, i) => ({
7 id: i,
8 name: `${adjectives[i % adjectives.length]} ${nouns[i % nouns.length]} ${i + 1}`,
9 description: `High-quality ${categories[i % categories.length].toLowerCase()} item with excellent features and durability.`,
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}W przeciwieństwie do debouncingu nie ma tu stałego opóźnienia: React opóźnia render tylko tak długo, jak wymaga tego urządzenie.
Najlepsze praktyki z useDeferredValue
1. Kiedy używać useDeferredValue
Poniższe przykłady pokazują, gdzie opóźnienie się opłaca, a gdzie tylko dokłada pracy. Kluczem jest koszt renderu komponentu, który dostaje opóźnioną wartość:
1// DOBRZE - Ciężkie listy i filtrowanie
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// DOBRZE - Renderowanie wykresów
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// ŹLE - Proste operacje
28function SimpleCounter() {
29 const [count, setCount] = useState(0);
30 const deferredCount = useDeferredValue(count); // Niepotrzebne!
31
32 return <div>{deferredCount}</div>;
33}Licznik w SimpleCounter renderuje jedną liczbę, więc opóźnianie niczego nie przyspieszy, a tylko doda drugi render.
2. Łączenie z innymi hookami
W tabeli danych łączymy useDeferredValue z useMemo i useCallback. Filtrowanie i sortowanie zależą tylko od opóźnionych wartości:
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 // Memoized filtering and sorting
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')}>Nazwa</th>
49 <th onClick={() => handleSort('category')}>Kategoria</th>
50 <th onClick={() => handleSort('price')}>Cena</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}Sortujemy kopię [...filtered], bo sort zmienia tablicę w miejscu i bez kopii psułby memoizowane data. Porównanie przez String z opcją numeric obsługuje też kolumnę z ceną, która jest liczbą.
3. Czego unikać
Ostatni przykład pokazuje nadmiar dobrych chęci. Trzy osobne opóźnienia dla prostego formularza tylko komplikują kod:
1// ŹLE - Nadmierne użycie
2function OverusedDeferred() {
3 const [name, setName] = useState('');
4 const [age, setAge] = useState(0);
5 const [email, setEmail] = useState('');
6
7 // Nie każda wartość potrzebuje deferred!
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// LEPIEJ - Selektywne użycie
16function BetterApproach() {
17 const [formData, setFormData] = useState({ name: '', age: 0, email: '' });
18 const deferredFormData = useDeferredValue(formData);
19
20 return <HeavyValidationForm data={deferredFormData} />;
21}Lepiej opóźnić jeden obiekt przekazywany do naprawdę ciężkiego komponentu.
useDeferredValue to elegancki sposób na poprawę wydajności aplikacji React bez skomplikowanego zarządzania stanem. Pozwala na zachowanie responsywności krytycznych elementów UI podczas wykonywania ciężkich operacji w tle.
Mierz, zanim zoptymalizujesz: wirtualizację długich list poznasz w kolejnej lekcji. Pamiętaj: opóźniona wartość to echo radaru, które mapa galaktyki odczyta, gdy tylko pilot skończy manewr.
Kod do tej lekcji: App.jsx
1import { useDeferredValue, useState, memo, useMemo } from 'react';
2
3// Symulacja ciężkiej operacji - renderowanie wielu elementów
4function generateItems(count, filter = '') {
5 const items = [];
6 for (let i = 0; i < count; i++) {
7 const name = `Produkt ${i + 1}`;
8 const category = ['Elektronika', 'Książki', 'Odzież', 'Dom', 'Sport'][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// Komponent zoptymalizowany z memo
27const ProductList = memo(function ProductList({ items, isStale }) {
28 // Symulacja ciężkich obliczeń
29 const processedItems = useMemo(() => {
30 return items.map(item => ({
31 ...item,
32 displayPrice: `${item.price} zł`,
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 Aktualizowanie wyników...
58 </div>
59 )}
60
61 {processedItems.length === 0 ? (
62 <div style={{
63 padding: '40px',
64 textAlign: 'center',
65 color: '#666'
66 }}>
67 Brak produktów spełniających kryteria
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 Znaleziono {processedItems.length} produktów
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 ? 'Na stanie' : 'Brak w magazynie'}
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 // DEFERRED VALUES - będą się aktualizować z opóźnieniem
161 const deferredQuery = useDeferredValue(query);
162 const deferredCategory = useDeferredValue(category);
163 const deferredSortBy = useDeferredValue(sortBy);
164
165 // Sprawdzamy czy wartości są "stare" (nieaktualne)
166 const isStale = deferredQuery !== query ||
167 deferredCategory !== category ||
168 deferredSortBy !== sortBy;
169
170 // Generowanie wyników na podstawie DEFERRED wartości
171 const results = useMemo(() => {
172 let items = generateItems(8000, deferredQuery);
173
174 // Filtrowanie po kategorii
175 if (deferredCategory) {
176 items = items.filter(item =>
177 item.category.toLowerCase().includes(deferredCategory.toLowerCase())
178 );
179 }
180
181 // Sortowanie
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>useDeferredValue Demo - Sklep Online</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 Wyszukaj produkt:
215 </label>
216 <input
217 value={query}
218 onChange={(e) => setQuery(e.target.value)}
219 placeholder="Wpisz nazwę produktu..."
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 Kategoria:
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="">Wszystkie kategorie</option>
255 <option value="Elektronika">Elektronika</option>
256 <option value="Książki">Książki</option>
257 <option value="Odzież">Odzież</option>
258 <option value="Dom">Dom</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 Sortuj według:
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">Nazwa</option>
286 <option value="price">Cena</option>
287 <option value="rating">Ocena</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>Status filtrów:</strong>
304 <ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
305 <li>Wyszukiwanie: "{query}" {deferredQuery !== query && '(aktualizowanie...)'}</li>
306 <li>Kategoria: {category || 'Wszystkie'} {deferredCategory !== category && '(aktualizowanie...)'}</li>
307 <li>Sortowanie: {sortBy} {deferredSortBy !== sortBy && '(aktualizowanie...)'}</li>
308 </ul>
309 </div>
310
311 <div style={{ textAlign: 'right' }}>
312 {isStale ? (
313 <span style={{ color: '#ff9800', fontWeight: 'bold' }}>
314 Aktualizowanie wyników...
315 </span>
316 ) : (
317 <span style={{ color: '#4caf50', fontWeight: 'bold' }}>
318 Wyniki aktualne
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>Co obserwować w tym demo:</strong>
334 <ul style={{ margin: '10px 0', paddingLeft: '20px' }}>
335 <li><strong>Responsywne inputy:</strong> Wszystkie kontrolki reagują natychmiast</li>
336 <li><strong>Odroczone renderowanie:</strong> Lista produktów aktualizuje się z opóźnieniem</li>
337 <li><strong>Wskaźnik "stale":</strong> Widoczny gdy dane są nieaktualne</li>
338 <li><strong>Przezroczystość:</strong> Lista staje się przezroczysta podczas aktualizacji</li>
339 <li><strong>Optymalizacja memo:</strong> Komponent się nie re-renderuje gdy props są identyczne</li>
340 </ul>
341
342 <div style={{
343 marginTop: '12px',
344 padding: '8px',
345 backgroundColor: '#fff3e0',
346 borderRadius: '4px'
347 }}>
348 <strong>Spróbuj:</strong> Szybko zmieniaj filtry i zobacz jak React priorytetyzuje
349 responsywność inputów nad renderowaniem listy produktów!
350 </div>
351 </div>
352 </div>
353 );
354}
355
356function ComparisonDemo() {
357 const [query, setQuery] = useState('');
358 const deferredQuery = useDeferredValue(query);
359
360 // Symulacja różnych sposobów obsługi
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>Porównanie: useDeferredValue vs bez optymalizacji</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 Z 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 Bez optymalizacji
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 Wyszukaj produkty:
416 </label>
417 <input
418 value={query}
419 onChange={(e) => setQuery(e.target.value)}
420 placeholder="Spróbuj szybko pisać i zobacz różnicę..."
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>Aktualna wartość: "{query}"</span>
440 <span>
441 {version === 'deferred' && deferredQuery !== query && (
442 <span style={{ color: '#ff9800' }}>
443 Renderowanie: "{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 Brak wyników
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} zł
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' ? 'Z useDeferredValue:' : 'Bez optymalizacji:'}
500 </strong>
501 <p style={{ margin: '8px 0' }}>
502 {version === 'deferred'
503 ? 'Input pozostaje responsywny, lista aktualizuje się z opóźnieniem przy szybkim pisaniu.'
504 : 'Każda zmiana w input natychmiast renderuje całą listę, co może powodować lagowanie.'
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 Sklep Online
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 Porównanie
555 </button>
556 </div>
557 </nav>
558
559 {activeDemo === 'shop' ? <DeferredValueDemo /> : <ComparisonDemo />}
560 </div>
561 );
562}Widzisz błąd w tej lekcji?
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż składnię hooka useDeferredValue:
- Układanie w poziomie
Ułóż składnię destrukturyzacji hooka useTransition: