Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
useTransition - nieblokujące aktualizacje UI
W tej lekcji3
Wyobraź sobie pulpit centrum kontroli misji. Operator wpisuje nazwę gwiazdy, a pod spodem filtruje się katalog dziesięciu tysięcy obiektów. Jeśli React potraktuje obie zmiany tak samo pilnie, każde naciśnięcie klawisza czeka na przerysowanie całej listy i pole tekstowe zaczyna się zacinać.
Hook useTransition, wprowadzony w React 18, pozwala oznaczyć część aktualizacji stanu jako "przejściowe" (transition). React najpierw obsługuje zmiany pilne, a render przejściowy może przerwać, gdy pojawi się coś ważniejszego.
Czego się nauczysz:
- jak rozdzielić aktualizacje na pilne i przejściowe,
- do czego służą
isPendingistartTransition, - jak
useTransitionwspółpracuje z Suspense, - kiedy transition pomaga, a kiedy szkodzi.
Podstawy useTransition
1. Składnia i podstawowe użycie
Hook zwraca parę: flagę isPending, mówiącą, że przejście trwa, oraz funkcję startTransition, do której przekazujesz zmiany stanu o niższym priorytecie. Zobacz to w wyszukiwarce:
1import { useTransition, useState } from 'react';
2
3function SearchComponent() {
4 const [query, setQuery] = useState('');
5 const [results, setResults] = useState([]);
6 const [isPending, startTransition] = useTransition();
7
8 const handleSearch = (searchTerm) => {
9 // Pilna aktualizacja - natychmiastowa
10 setQuery(searchTerm);
11
12 // Przejściowa aktualizacja - może być przerwana
13 startTransition(() => {
14 // Ciężkie obliczenia lub duże listy
15 const searchResults = performHeavySearch(searchTerm);
16 setResults(searchResults);
17 });
18 };
19
20 return (
21 <div>
22 <input
23 value={query}
24 onChange={(e) => handleSearch(e.target.value)}
25 />
26
27 {isPending && <div>Wyszukiwanie...</div>}
28
29 <div>
30 {results.map(result => (
31 <div key={result.id}>{result.title}</div>
32 ))}
33 </div>
34 </div>
35 );
36}
37
38function performHeavySearch(term) {
39 // Symulacja ciężkich obliczeń
40 const items = [];
41 for (let i = 0; i < 10000; i++) {
42 if (`Item ${i}`.toLowerCase().includes(term.toLowerCase())) {
43 items.push({ id: i, title: `Item ${i}` });
44 }
45 }
46 return items;
47}Wpisany tekst trafia do setQuery od razu, więc pole reaguje natychmiast. Aktualizacja setResults jest przejściowa: jeśli operator wpisze kolejną literę, zanim lista się przerysuje, React porzuci nieaktualny render. Ważny szczegół z dokumentacji react.dev: sam callback przekazany do startTransition wykonuje się od razu i synchronicznie. Przerywalny jest render wywołany przez setResults, a nie pętla w performHeavySearch.
2. Różnica między pilnymi a przejściowymi aktualizacjami
Teraz ten sam podział na większej liście. Pole filtra to aktualizacja pilna, bo użytkownik musi widzieć swoje litery. Przefiltrowana lista może chwilę poczekać:
1import { useTransition, useState } from 'react';
2
3function ResponsiveList() {
4 const [filter, setFilter] = useState('');
5 const [allItems] = useState(() => generateItems(10000));
6 const [items, setItems] = useState(allItems);
7 const [isPending, startTransition] = useTransition();
8
9 const handleFilterChange = (newFilter) => {
10 // PILNA aktualizacja - input musi być responsywny
11 setFilter(newFilter);
12
13 // PRZEJŚCIOWA aktualizacja - może poczekać
14 startTransition(() => {
15 const filtered = allItems.filter(item =>
16 item.name.toLowerCase().includes(newFilter.toLowerCase())
17 );
18 setItems(filtered);
19 });
20 };
21
22 return (
23 <div>
24 <input
25 value={filter}
26 onChange={(e) => handleFilterChange(e.target.value)}
27 style={{
28 padding: '8px',
29 border: '1px solid #ccc',
30 borderRadius: '4px'
31 }}
32 />
33
34 <div style={{ marginTop: '10px' }}>
35 {isPending ? (
36 <div style={{ color: '#666' }}>
37 Aktualizowanie listy...
38 </div>
39 ) : (
40 <div>Znaleziono {items.length} elementów</div>
41 )}
42 </div>
43
44 <div style={{ height: '400px', overflow: 'auto', marginTop: '10px' }}>
45 {items.map(item => (
46 <div
47 key={item.id}
48 style={{
49 padding: '8px',
50 borderBottom: '1px solid #eee',
51 backgroundColor: item.id % 2 === 0 ? '#f9f9f9' : '#fff'
52 }}
53 >
54 <strong>{item.name}</strong> - {item.description}
55 </div>
56 ))}
57 </div>
58 </div>
59 );
60}
61
62function generateItems(count) {
63 return Array.from({ length: count }, (_, i) => ({
64 id: i,
65 name: `Element ${i + 1}`,
66 description: `Opis elementu numer ${i + 1}`
67 }));
68}Flaga isPending pokazuje komunikat "Aktualizowanie listy...", gdy stara lista wciąż jest na ekranie. Zwróć uwagę, że filtrujemy zawsze pełny katalog allItems, a nie wynik poprzedniego filtrowania. Gdyby filtr działał na już przefiltrowanej liście, skasowanie litery nie przywróciłoby znikniętych elementów.
3. Współpraca z Suspense
Przejścia świetnie łączą się z Suspense. Najpierw zasób, który potrafi "zawiesić" komponent. Funkcja createResource owija Promise i rzuca go, dopóki dane nie dotrą:
1import { Suspense, useTransition, useState } from 'react';
2
3// Symulacja asynchronicznego zasobu
4function createResource(promise) {
5 let status = 'pending';
6 let result;
7
8 const suspender = promise.then(
9 (response) => {
10 status = 'success';
11 result = response;
12 },
13 (error) => {
14 status = 'error';
15 result = error;
16 }
17 );
18
19 return {
20 read() {
21 if (status === 'pending') {
22 throw suspender;
23 } else if (status === 'error') {
24 throw result;
25 } else if (status === 'success') {
26 return result;
27 }
28 }
29 };
30}
31Metoda read() zwraca dane albo rzuca Promise, a React łapie go w najbliższej granicy Suspense. Tu czai się pułapka: jeśli komponent tworzyłby nowy zasób przy każdym renderze, każdy render zaczynałby nowe zapytanie i nigdy by się nie zakończył. Dlatego trzymamy zasoby w prostym cache, po jednym na każdego użytkownika:
1// Cache zasobów: ten sam userId = ten sam Promise
2const userResources = new Map();
3
4function getUserResource(userId) {
5 if (!userResources.has(userId)) {
6 userResources.set(userId, createResource(
7 fetch(`/api/users/${userId}`).then(res => res.json())
8 ));
9 }
10 return userResources.get(userId);
11}Ten sam userId daje ten sam obiekt, więc zapytanie wychodzi raz. Teraz profil może bezpiecznie czytać dane:
1// Komponent wykorzystujący zasób
2function UserProfile({ userId }) {
3 const userResource = getUserResource(userId);
4
5 const user = userResource.read();
6
7 return (
8 <div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
9 <h2>{user.name}</h2>
10 <p>Email: {user.email}</p>
11 <p>ID: {user.id}</p>
12 </div>
13 );
14}
15Komponent wygląda na synchroniczny, bo o czekanie dba Suspense. Pozostaje lista przełączająca użytkowników w przejściu:
1// Główny komponent z useTransition
2function UsersList() {
3 const [selectedUserId, setSelectedUserId] = useState(1);
4 const [isPending, startTransition] = useTransition();
5
6 const users = [
7 { id: 1, name: 'Anna Kowalska' },
8 { id: 2, name: 'Jan Nowak' },
9 { id: 3, name: 'Maria Wiśniewska' }
10 ];
11
12 const handleUserSelect = (userId) => {
13 startTransition(() => {
14 setSelectedUserId(userId);
15 });
16 };
17
18 return (
19 <div style={{ display: 'flex', gap: '20px' }}>
20 <div style={{ width: '200px' }}>
21 <h3>Wybierz użytkownika:</h3>
22 {users.map(user => (
23 <button
24 key={user.id}
25 onClick={() => handleUserSelect(user.id)}
26 style={{
27 display: 'block',
28 width: '100%',
29 margin: '5px 0',
30 padding: '10px',
31 backgroundColor: selectedUserId === user.id ? '#007bff' : '#f8f9fa',
32 color: selectedUserId === user.id ? 'white' : 'black',
33 border: '1px solid #ddd',
34 borderRadius: '4px',
35 cursor: 'pointer'
36 }}
37 >
38 {user.name}
39 </button>
40 ))}
41 </div>
42
43 <div style={{ flex: 1 }}>
44 <Suspense
45 fallback={
46 <div style={{
47 padding: '20px',
48 textAlign: 'center',
49 border: '1px dashed #ddd',
50 borderRadius: '8px'
51 }}>
52 {isPending ? 'Ładowanie nowego użytkownika...' : 'Ładowanie...'}
53 </div>
54 }
55 >
56 <UserProfile userId={selectedUserId} />
57 </Suspense>
58 </div>
59 </div>
60 );
61}Bez startTransition kliknięcie innego członka załogi od razu schowałoby profil za fallbackiem. Z przejściem React dłużej pokazuje poprzedni profil, aż nowe dane będą gotowe. Samo rzucanie obietnicy z read() to uproszczony model tego, co robią biblioteki danych. W React 19 oficjalnym sposobem czytania obietnicy w Suspense jest hook use, który poznasz w jednej z kolejnych lekcji.
Zaawansowane wzorce z useTransition
1. Optymistyczne aktualizacje
Od React 19 do startTransition można przekazać funkcję asynchroniczną. Taką funkcję dokumentacja nazywa Akcją, a isPending pozostaje true aż do jej zakończenia. Najpierw logika listy zadań:
1import { useTransition, useState } from 'react';
2
3function TodoApp() {
4 const [todos, setTodos] = useState([
5 { id: 1, text: 'Kupić mleko', completed: false },
6 { id: 2, text: 'Napisać raport', completed: true }
7 ]);
8 const [isPending, startTransition] = useTransition();
9
10 const addTodo = async (text) => {
11 const optimisticTodo = {
12 id: Date.now(),
13 text,
14 completed: false,
15 isPending: true
16 };
17
18 // Optymistyczna aktualizacja
19 setTodos(prev => [...prev, optimisticTodo]);
20
21 startTransition(async () => {
22 try {
23 // Symulacja API call
24 await new Promise(resolve => setTimeout(resolve, 1000));
25
26 // Aktualizacja po pomyślnym wykonaniu
27 setTodos(prev =>
28 prev.map(todo =>
29 todo.id === optimisticTodo.id
30 ? { ...todo, isPending: false }
31 : todo
32 )
33 );
34 } catch (error) {
35 // Cofnięcie optymistycznej aktualizacji
36 setTodos(prev => prev.filter(todo => todo.id !== optimisticTodo.id));
37 alert('Błąd przy dodawaniu zadania');
38 }
39 });
40 };
41
42 const toggleTodo = async (id) => {
43 // Optymistyczna aktualizacja
44 setTodos(prev =>
45 prev.map(todo =>
46 todo.id === id ? { ...todo, completed: !todo.completed, isPending: true } : todo
47 )
48 );
49
50 startTransition(async () => {
51 try {
52 await new Promise(resolve => setTimeout(resolve, 500));
53
54 setTodos(prev =>
55 prev.map(todo =>
56 todo.id === id ? { ...todo, isPending: false } : todo
57 )
58 );
59 } catch (error) {
60 // Cofnięcie zmiany
61 setTodos(prev =>
62 prev.map(todo =>
63 todo.id === id ? { ...todo, completed: !todo.completed, isPending: false } : todo
64 )
65 );
66 }
67 });
68 };
69Zadanie pojawia się od razu, a potwierdzenie przychodzi po sekundzie. Uwaga z react.dev: aktualizacje stanu wykonane po await nie są już oznaczone jako przejście, więc w produkcyjnym kodzie owija się je w kolejne startTransition. Hook useOptimistic, który robi to samo krócej i sam cofa zmianę, poznasz w osobnej lekcji. Oto widok, który korzysta z isPending:
1 return (
2 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
3 <h2>Lista zadań</h2>
4
5 <form
6 onSubmit={(e) => {
7 e.preventDefault();
8 const formData = new FormData(e.target);
9 const text = formData.get('text');
10 if (text) {
11 addTodo(text);
12 e.target.reset();
13 }
14 }}
15 style={{ marginBottom: '20px' }}
16 >
17 <input
18 name="text"
19 style={{ padding: '8px', marginRight: '8px', flex: 1 }}
20 />
21 <button
22 type="submit"
23 disabled={isPending}
24 style={{
25 padding: '8px 16px',
26 backgroundColor: '#007bff',
27 color: 'white',
28 border: 'none',
29 borderRadius: '4px',
30 cursor: isPending ? 'not-allowed' : 'pointer'
31 }}
32 >
33 {isPending ? 'Dodawanie...' : 'Dodaj'}
34 </button>
35 </form>
36
37 <ul style={{ listStyle: 'none', padding: 0 }}>
38 {todos.map(todo => (
39 <li
40 key={todo.id}
41 style={{
42 display: 'flex',
43 alignItems: 'center',
44 padding: '10px',
45 marginBottom: '5px',
46 backgroundColor: '#f8f9fa',
47 borderRadius: '4px',
48 opacity: todo.isPending ? 0.6 : 1
49 }}
50 >
51 <input
52 type="checkbox"
53 checked={todo.completed}
54 onChange={() => toggleTodo(todo.id)}
55 disabled={todo.isPending}
56 style={{ marginRight: '10px' }}
57 />
58 <span
59 style={{
60 textDecoration: todo.completed ? 'line-through' : 'none',
61 flex: 1
62 }}
63 >
64 {todo.text}
65 </span>
66 {todo.isPending && (
67 <span style={{ color: '#666', fontSize: '12px' }}>
68 Aktualizowanie...
69 </span>
70 )}
71 </li>
72 ))}
73 </ul>
74 </div>
75 );
76}Przycisk "Dodaj" jest zablokowany przez cały czas trwania Akcji, a elementy w trakcie zapisu dostają obniżoną przezroczystość.
2. Debouncing z useTransition
Transition nie zastępuje debouncingu, tylko go uzupełnia. Najpierw stan i dwa efekty: jeden opóźnia zapytanie o 300 ms, drugi uruchamia wyszukiwanie w przejściu:
1import { useTransition, useState, useEffect } from 'react';
2
3function SearchWithDebouncing() {
4 const [query, setQuery] = useState('');
5 const [debouncedQuery, setDebouncedQuery] = useState('');
6 const [results, setResults] = useState([]);
7 const [isPending, startTransition] = useTransition();
8
9 // Debouncing input
10 useEffect(() => {
11 const timer = setTimeout(() => {
12 setDebouncedQuery(query);
13 }, 300);
14
15 return () => clearTimeout(timer);
16 }, [query]);
17
18 // Wyszukiwanie po zmianie debounced query
19 useEffect(() => {
20 if (debouncedQuery) {
21 startTransition(() => {
22 const searchResults = performSearch(debouncedQuery);
23 setResults(searchResults);
24 });
25 } else {
26 setResults([]);
27 }
28 }, [debouncedQuery]);
29Debouncing ogranicza liczbę wyszukiwań, a transition chroni pisanie przed ciężkim renderem. Teraz interfejs:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Wyszukiwarka z debouncing</h2>
4
5 <div style={{ position: 'relative', marginBottom: '20px' }}>
6 <input
7 value={query}
8 onChange={(e) => setQuery(e.target.value)}
9 style={{
10 width: '100%',
11 padding: '12px',
12 fontSize: '16px',
13 border: '2px solid #ddd',
14 borderRadius: '8px',
15 outline: 'none'
16 }}
17 />
18 {isPending && (
19 <div style={{
20 position: 'absolute',
21 right: '10px',
22 top: '50%',
23 transform: 'translateY(-50%)',
24 color: '#666'
25 }}>
26 Wyszukiwanie...
27 </div>
28 )}
29 </div>
30
31 <div>
32 <p style={{ color: '#666', margin: '10px 0' }}>
33 Znaleziono {results.length} wyników
34 {debouncedQuery && ` dla "${debouncedQuery}"`}
35 </p>
36
37 <div style={{ maxHeight: '400px', overflowY: 'auto' }}>
38 {results.map(result => (
39 <div
40 key={result.id}
41 style={{
42 padding: '12px',
43 marginBottom: '8px',
44 backgroundColor: '#f8f9fa',
45 border: '1px solid #e9ecef',
46 borderRadius: '6px'
47 }}
48 >
49 <h4 style={{ margin: '0 0 5px 0', color: '#007bff' }}>
50 {highlightMatch(result.title, debouncedQuery)}
51 </h4>
52 <p style={{ margin: 0, color: '#666', fontSize: '14px' }}>
53 {highlightMatch(result.description, debouncedQuery)}
54 </p>
55 </div>
56 ))}
57 </div>
58 </div>
59 </div>
60 );
61}
62Pole nadal czyta query, a wyniki czytają debouncedQuery, więc input i lista się nie blokują. Na koniec funkcje pomocnicze:
1function performSearch(query) {
2 const items = [
3 { id: 1, title: 'React Hooks', description: 'Nowoczesny sposób na zarządzanie stanem w React' },
4 { id: 2, title: 'JavaScript ES6+', description: 'Najnowsze funkcje JavaScript' },
5 { id: 3, title: 'TypeScript Guide', description: 'Kompletny przewodnik po TypeScript' },
6 { id: 4, title: 'Node.js Backend', description: 'Tworzenie serwerów z Node.js' },
7 { id: 5, title: 'CSS Grid Layout', description: 'Zaawansowane układy CSS' }
8 ];
9
10 return items.filter(item =>
11 item.title.toLowerCase().includes(query.toLowerCase()) ||
12 item.description.toLowerCase().includes(query.toLowerCase())
13 );
14}
15
16function highlightMatch(text, query) {
17 if (!query) return text;
18
19 const regex = new RegExp(`(${query})`, 'gi');
20 const parts = text.split(regex);
21
22 return parts.map((part, index) =>
23 part.toLowerCase() === query.toLowerCase() ? (
24 <mark key={index} style={{ backgroundColor: '#ffeb3b', padding: '2px' }}>
25 {part}
26 </mark>
27 ) : part
28 );
29}Funkcja highlightMatch buduje wyrażenie regularne z tekstu użytkownika, więc w prawdziwej aplikacji zabezpiecz najpierw znaki specjalne, np. nawiasy.
3. Koordynacja kilku przejść
Jeden komponent może mieć kilka niezależnych przejść. Każde wywołanie useTransition daje osobną flagę isPending, więc wiesz, która część pulpitu jeszcze pracuje:
1import { useTransition, useState } from 'react';
2
3function MultipleTransitionsDemo() {
4 const [activeTab, setActiveTab] = useState('users');
5 const [filter, setFilter] = useState('');
6 const [sortBy, setSortBy] = useState('name');
7
8 const [tabPending, startTabTransition] = useTransition();
9 const [filterPending, startFilterTransition] = useTransition();
10 const [sortPending, startSortTransition] = useTransition();
11
12 const data = {
13 users: [
14 { id: 1, name: 'Anna Kowalska', email: 'anna@example.com', role: 'Admin' },
15 { id: 2, name: 'Jan Nowak', email: 'jan@example.com', role: 'User' },
16 { id: 3, name: 'Maria Wiśniewska', email: 'maria@example.com', role: 'Editor' }
17 ],
18 products: [
19 { id: 1, name: 'Laptop', price: 2500, category: 'Electronics' },
20 { id: 2, name: 'Książka', price: 45, category: 'Books' },
21 { id: 3, name: 'Słuchawki', price: 150, category: 'Electronics' }
22 ]
23 };
24
25 const handleTabChange = (tab) => {
26 startTabTransition(() => {
27 setActiveTab(tab);
28 setFilter(''); // Reset filter when changing tabs
29 });
30 };
31
32 const handleFilterChange = (newFilter) => {
33 setFilter(newFilter); // Immediate update for input
34 startFilterTransition(() => {
35 // Any heavy filtering logic would go here
36 });
37 };
38
39 const handleSortChange = (newSort) => {
40 startSortTransition(() => {
41 setSortBy(newSort);
42 });
43 };
44
45 const filteredAndSortedData = data[activeTab]
46 .filter(item =>
47 Object.values(item).some(value =>
48 value.toString().toLowerCase().includes(filter.toLowerCase())
49 )
50 )
51 .sort((a, b) => {
52 if (sortBy === 'name') return a.name.localeCompare(b.name);
53 if (sortBy === 'price') return (a.price || 0) - (b.price || 0);
54 return 0;
55 });
56
57 const isPending = tabPending || filterPending || sortPending;
58Zakładki i sortowanie idą przez przejścia, a setFilter jest pilny, bo steruje polem tekstowym. Puste startFilterTransition to tylko miejsce na przyszłą ciężką logikę. Dokumentacja dodaje, że React obecnie łączy kilka trwających przejść w jedną partię. Oto widok:
1 return (
2 <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
3 <h2>Multiple Transitions Demo</h2>
4
5 {/* Tab Navigation */}
6 <div style={{ marginBottom: '20px', borderBottom: '1px solid #ddd' }}>
7 {['users', 'products'].map(tab => (
8 <button
9 key={tab}
10 onClick={() => handleTabChange(tab)}
11 disabled={tabPending}
12 style={{
13 padding: '10px 20px',
14 marginRight: '10px',
15 backgroundColor: activeTab === tab ? '#007bff' : 'transparent',
16 color: activeTab === tab ? 'white' : '#007bff',
17 border: `2px solid ${activeTab === tab ? '#007bff' : '#ddd'}`,
18 borderBottom: 'none',
19 borderRadius: '8px 8px 0 0',
20 cursor: tabPending ? 'not-allowed' : 'pointer',
21 opacity: tabPending ? 0.6 : 1
22 }}
23 >
24 {tab.charAt(0).toUpperCase() + tab.slice(1)}
25 {tabPending && ' ...'}
26 </button>
27 ))}
28 </div>
29
30 {/* Controls */}
31 <div style={{
32 display: 'flex',
33 gap: '15px',
34 marginBottom: '20px',
35 alignItems: 'center'
36 }}>
37 <input
38 value={filter}
39 onChange={(e) => handleFilterChange(e.target.value)}
40 style={{
41 padding: '8px',
42 border: '1px solid #ddd',
43 borderRadius: '4px',
44 flex: 1
45 }}
46 />
47
48 <select
49 value={sortBy}
50 onChange={(e) => handleSortChange(e.target.value)}
51 style={{
52 padding: '8px',
53 border: '1px solid #ddd',
54 borderRadius: '4px'
55 }}
56 >
57 <option value="name">Sortuj po nazwie</option>
58 {activeTab === 'products' && <option value="price">Sortuj po cenie</option>}
59 </select>
60
61 {isPending && (
62 <div style={{ color: '#666', fontSize: '14px' }}>
63 Aktualizowanie...
64 </div>
65 )}
66 </div>
67
68 {/* Data Display */}
69 <div style={{
70 minHeight: '200px',
71 opacity: isPending ? 0.7 : 1,
72 transition: 'opacity 0.2s'
73 }}>
74 {filteredAndSortedData.length === 0 ? (
75 <div style={{
76 textAlign: 'center',
77 color: '#666',
78 padding: '40px'
79 }}>
80 Brak wyników
81 </div>
82 ) : (
83 <div style={{ display: 'grid', gap: '10px' }}>
84 {filteredAndSortedData.map(item => (
85 <div
86 key={item.id}
87 style={{
88 padding: '15px',
89 backgroundColor: '#f8f9fa',
90 border: '1px solid #e9ecef',
91 borderRadius: '6px'
92 }}
93 >
94 <h4 style={{ margin: '0 0 5px 0' }}>{item.name}</h4>
95 {activeTab === 'users' ? (
96 <div>
97 <p style={{ margin: '2px 0', color: '#666' }}>
98 {item.email}
99 </p>
100 <p style={{ margin: '2px 0', color: '#666' }}>
101 {item.role}
102 </p>
103 </div>
104 ) : (
105 <div>
106 <p style={{ margin: '2px 0', color: '#666' }}>
107 {item.price} zł
108 </p>
109 <p style={{ margin: '2px 0', color: '#666' }}>
110 {item.category}
111 </p>
112 </div>
113 )}
114 </div>
115 ))}
116 </div>
117 )}
118 </div>
119 </div>
120 );
121}Wspólna flaga isPending przyciemnia dane, a przyciski zakładek reagują tylko na tabPending, więc każda część pulpitu zna własny stan przejścia.
Najlepsze praktyki z useTransition
1. Kiedy używać useTransition
- Duże listy danych - filtrowanie, sortowanie
- Ciężkie obliczenia - które mogą zablokować UI
- Nawigacja - przełączanie między widokami
- Optymistyczne aktualizacje - przed potwierdzeniem z serwera
2. Czego unikać
- Nie używaj dla krytycznych aktualizacji (input values, focus management)
- Nie nadużywaj - zbyt wiele transitions może pogorszyć wydajność
- Uważaj na race conditions między różnymi transitions
Moja rada: zacznij od zwykłego stanu, a po useTransition sięgaj dopiero wtedy, gdy zmierzysz, że render blokuje wpisywanie. W tym module poznasz też useDeferredValue, bliźniaczy hook na sytuacje, w których nie kontrolujesz miejsca aktualizacji stanu.
useTransition to potężne narzędzie do utrzymania responsywności aplikacji React. Pozwala na inteligentne zarządzanie priorytetami aktualizacji, dzięki czemu użytkownik zawsze ma wrażenie płynnego działania interfejsu.
Pamiętaj: startTransition to komunikat dla kontroli misji, że ten manewr może poczekać, gdy pilot właśnie steruje statkiem.
Kod do tej lekcji: App.jsx
1import { useTransition, useState } from 'react';
2
3// Symulacja ciężkich obliczeń
4function generateItems(count, filter = '') {
5 const items = [];
6 for (let i = 0; i < count; i++) {
7 const name = `Element ${i + 1}`;
8 if (filter === '' || name.toLowerCase().includes(filter.toLowerCase())) {
9 items.push({
10 id: i,
11 name,
12 description: `Opis dla elementu numer ${i + 1}`,
13 price: Math.floor(Math.random() * 1000) + 50
14 });
15 }
16 }
17 return items;
18}
19
20function SearchDemo() {
21 const [query, setQuery] = useState('');
22 const [results, setResults] = useState(() => generateItems(5000));
23 const [isPending, startTransition] = useTransition();
24
25 const handleSearch = (searchTerm) => {
26 // PILNA aktualizacja - input musi być responsywny
27 setQuery(searchTerm);
28
29 // PRZEJŚCIOWA aktualizacja - może być przerwana
30 startTransition(() => {
31 const filtered = generateItems(5000, searchTerm);
32 setResults(filtered);
33 });
34 };
35
36 return (
37 <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
38 <h2>useTransition Demo - Responsywne wyszukiwanie</h2>
39
40 <div style={{ marginBottom: '20px' }}>
41 <input
42 value={query}
43 onChange={(e) => handleSearch(e.target.value)}
44 placeholder="Wpisz numer elementu (np. 100)..."
45 style={{
46 width: '100%',
47 padding: '12px',
48 fontSize: '16px',
49 border: '2px solid #ddd',
50 borderRadius: '8px',
51 outline: 'none',
52 boxSizing: 'border-box'
53 }}
54 />
55
56 <div style={{
57 marginTop: '10px',
58 display: 'flex',
59 justifyContent: 'space-between',
60 alignItems: 'center',
61 fontSize: '14px',
62 color: '#666'
63 }}>
64 <span>
65 {isPending ? (
66 <span style={{ color: '#007bff' }}>
67 Filtrowanie {query ? `"${query}"` : 'wszystkich elementów'}...
68 </span>
69 ) : (
70 `Znaleziono ${results.length} elementów`
71 )}
72 </span>
73
74 <span style={{ fontSize: '12px' }}>
75 Spróbuj szybko pisać - input pozostaje responsywny!
76 </span>
77 </div>
78 </div>
79
80 <div style={{
81 height: '400px',
82 overflow: 'auto',
83 border: '1px solid #ddd',
84 borderRadius: '8px',
85 backgroundColor: '#f9f9f9'
86 }}>
87 {results.length === 0 ? (
88 <div style={{
89 padding: '40px',
90 textAlign: 'center',
91 color: '#666'
92 }}>
93 Brak wyników dla "{query}"
94 </div>
95 ) : (
96 results.map(item => (
97 <div
98 key={item.id}
99 style={{
100 padding: '12px',
101 borderBottom: '1px solid #eee',
102 backgroundColor: item.id % 2 === 0 ? '#fff' : '#f9f9f9',
103 display: 'flex',
104 justifyContent: 'space-between',
105 alignItems: 'center'
106 }}
107 >
108 <div>
109 <div style={{ fontWeight: 'bold', color: '#333' }}>
110 {item.name}
111 </div>
112 <div style={{ fontSize: '14px', color: '#666' }}>
113 {item.description}
114 </div>
115 </div>
116 <div style={{
117 fontWeight: 'bold',
118 color: '#007bff',
119 fontSize: '16px'
120 }}>
121 {item.price} zł
122 </div>
123 </div>
124 ))
125 )}
126 </div>
127
128 <div style={{
129 marginTop: '20px',
130 padding: '15px',
131 backgroundColor: '#e3f2fd',
132 borderRadius: '8px',
133 fontSize: '14px'
134 }}>
135 <strong>Co obserwować:</strong>
136 <ul style={{ margin: '10px 0', paddingLeft: '20px' }}>
137 <li>Input jest zawsze responsywny (pilna aktualizacja)</li>
138 <li>Lista może się aktualizować z opóźnieniem (przejściowa aktualizacja)</li>
139 <li>Podczas filtrowania pojawia się wskaźnik "Filtrowanie..."</li>
140 <li>React może przerwać renderowanie listy dla nowych wpisów</li>
141 </ul>
142 </div>
143 </div>
144 );
145}
146
147function TodoDemo() {
148 const [todos, setTodos] = useState([
149 { id: 1, text: 'Kupić mleko', completed: false, isPending: false },
150 { id: 2, text: 'Napisać raport', completed: true, isPending: false }
151 ]);
152 const [isPending, startTransition] = useTransition();
153
154 const addTodo = async (text) => {
155 const newTodo = {
156 id: Date.now(),
157 text,
158 completed: false,
159 isPending: true
160 };
161
162 // Optymistyczna aktualizacja - natychmiast dodajemy todo
163 setTodos(prev => [...prev, newTodo]);
164
165 startTransition(async () => {
166 try {
167 // Symulacja API call
168 await new Promise(resolve => setTimeout(resolve, 1500));
169
170 // Aktualizacja po "pomyślnym" wykonaniu
171 setTodos(prev =>
172 prev.map(todo =>
173 todo.id === newTodo.id
174 ? { ...todo, isPending: false }
175 : todo
176 )
177 );
178 } catch (error) {
179 // W przypadku błędu - usuń optymistyczne todo
180 setTodos(prev => prev.filter(todo => todo.id !== newTodo.id));
181 alert('Błąd przy dodawaniu zadania!');
182 }
183 });
184 };
185
186 const toggleTodo = async (id) => {
187 // Optymistyczna aktualizacja
188 setTodos(prev =>
189 prev.map(todo =>
190 todo.id === id
191 ? { ...todo, completed: !todo.completed, isPending: true }
192 : todo
193 )
194 );
195
196 startTransition(async () => {
197 try {
198 await new Promise(resolve => setTimeout(resolve, 800));
199
200 setTodos(prev =>
201 prev.map(todo =>
202 todo.id === id ? { ...todo, isPending: false } : todo
203 )
204 );
205 } catch (error) {
206 // Cofnij zmianę w przypadku błędu
207 setTodos(prev =>
208 prev.map(todo =>
209 todo.id === id
210 ? { ...todo, completed: !todo.completed, isPending: false }
211 : todo
212 )
213 );
214 }
215 });
216 };
217
218 return (
219 <div style={{ padding: '20px', maxWidth: '500px', margin: '0 auto' }}>
220 <h2>Optymistyczne aktualizacje z useTransition</h2>
221
222 <form
223 onSubmit={(e) => {
224 e.preventDefault();
225 const formData = new FormData(e.target);
226 const text = formData.get('text');
227 if (text) {
228 addTodo(text);
229 e.target.reset();
230 }
231 }}
232 style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}
233 >
234 <input
235 name="text"
236 placeholder="Nowe zadanie..."
237 style={{
238 flex: 1,
239 padding: '10px',
240 border: '1px solid #ddd',
241 borderRadius: '4px'
242 }}
243 />
244 <button
245 type="submit"
246 disabled={isPending}
247 style={{
248 padding: '10px 20px',
249 backgroundColor: isPending ? '#ccc' : '#007bff',
250 color: 'white',
251 border: 'none',
252 borderRadius: '4px',
253 cursor: isPending ? 'not-allowed' : 'pointer',
254 whiteSpace: 'nowrap'
255 }}
256 >
257 {isPending ? 'Dodawanie...' : 'Dodaj'}
258 </button>
259 </form>
260
261 <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
262 {todos.map(todo => (
263 <li
264 key={todo.id}
265 style={{
266 display: 'flex',
267 alignItems: 'center',
268 padding: '12px',
269 marginBottom: '8px',
270 backgroundColor: '#f8f9fa',
271 borderRadius: '6px',
272 border: '1px solid #e9ecef',
273 opacity: todo.isPending ? 0.7 : 1,
274 transition: 'opacity 0.2s'
275 }}
276 >
277 <input
278 type="checkbox"
279 checked={todo.completed}
280 onChange={() => toggleTodo(todo.id)}
281 disabled={todo.isPending}
282 style={{ marginRight: '12px', transform: 'scale(1.2)' }}
283 />
284
285 <span
286 style={{
287 flex: 1,
288 textDecoration: todo.completed ? 'line-through' : 'none',
289 color: todo.completed ? '#666' : '#333',
290 fontSize: '16px'
291 }}
292 >
293 {todo.text}
294 </span>
295
296 {todo.isPending && (
297 <span style={{
298 color: '#007bff',
299 fontSize: '12px',
300 fontStyle: 'italic'
301 }}>
302 Synchronizacja...
303 </span>
304 )}
305 </li>
306 ))}
307 </ul>
308
309 <div style={{
310 marginTop: '20px',
311 padding: '15px',
312 backgroundColor: '#fff3cd',
313 borderRadius: '8px',
314 fontSize: '14px'
315 }}>
316 <strong>Optymistyczne aktualizacje:</strong>
317 <ul style={{ margin: '10px 0', paddingLeft: '20px' }}>
318 <li>Zmiany są widoczne natychmiast</li>
319 <li>W tle synchronizacja z "serwerem"</li>
320 <li>Wskaźniki ładowania dla długich operacji</li>
321 <li>Rollback w przypadku błędów</li>
322 </ul>
323 </div>
324 </div>
325 );
326}
327
328export default function App() {
329 const [activeDemo, setActiveDemo] = useState('search');
330
331 return (
332 <div style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
333 <nav style={{
334 backgroundColor: '#fff',
335 padding: '10px 0',
336 borderBottom: '1px solid #ddd',
337 marginBottom: '20px'
338 }}>
339 <div style={{
340 maxWidth: '800px',
341 margin: '0 auto',
342 display: 'flex',
343 gap: '10px',
344 padding: '0 20px'
345 }}>
346 <button
347 onClick={() => setActiveDemo('search')}
348 style={{
349 padding: '10px 20px',
350 backgroundColor: activeDemo === 'search' ? '#007bff' : '#f8f9fa',
351 color: activeDemo === 'search' ? 'white' : '#333',
352 border: '1px solid #ddd',
353 borderRadius: '4px',
354 cursor: 'pointer'
355 }}
356 >
357 Wyszukiwanie
358 </button>
359 <button
360 onClick={() => setActiveDemo('todos')}
361 style={{
362 padding: '10px 20px',
363 backgroundColor: activeDemo === 'todos' ? '#007bff' : '#f8f9fa',
364 color: activeDemo === 'todos' ? 'white' : '#333',
365 border: '1px solid #ddd',
366 borderRadius: '4px',
367 cursor: 'pointer'
368 }}
369 >
370 Lista zadań
371 </button>
372 </div>
373 </nav>
374
375 {activeDemo === 'search' ? <SearchDemo /> : <TodoDemo />}
376 </div>
377 );
378}Widzisz błąd w tej lekcji?