Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React

useDeferredValue - opóźnione wartości dla wydajności

15 min czytania
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}
45

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

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

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

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

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

Przydatne artykuły