Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

useTransition - nieblokujące aktualizacje UI

Hook

useTransition
jest jedną z najważniejszych funkcji React 18, która pozwala na oznaczanie aktualizacji stanu jako "przejściowych" (transition). Dzięki temu React może przerwać renderowanie tych aktualizacji, jeśli pojawią się bardziej pilne zmiany, co sprawia, że interfejs użytkownika pozostaje responsywny.

Podstawy useTransition

1. Składnia i podstawowe użycie

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}

2. Różnica między pilnymi a przejściowymi aktualizacjami

1import { useTransition, useState } from 'react';
2
3function ResponsiveList() {
4  const [filter, setFilter] = useState('');
5  const [items, setItems] = useState(generateItems(10000));
6  const [isPending, startTransition] = useTransition();
7
8  const handleFilterChange = (newFilter) => {
9    // PILNA aktualizacja - input musi być responsywny
10    setFilter(newFilter);
11    
12    // PRZEJŚCIOWA aktualizacja - może poczekać
13    startTransition(() => {
14      const filtered = items.filter(item => 
15        item.name.toLowerCase().includes(newFilter.toLowerCase())
16      );
17      setItems(filtered);
18    });
19  };
20
21  return (
22    <div>
23      <input
24        value={filter}
25        onChange={(e) => handleFilterChange(e.target.value)}
26        style={{
27          padding: '8px',
28          border: '1px solid #ccc',
29          borderRadius: '4px'
30        }}
31      />
32      
33      <div style={{ marginTop: '10px' }}>
34        {isPending ? (
35          <div style={{ color: '#666' }}>
36            Aktualizowanie listy...
37          </div>
38        ) : (
39          <div>Znaleziono {items.length} elementów</div>
40        )}
41      </div>
42      
43      <div style={{ height: '400px', overflow: 'auto', marginTop: '10px' }}>
44        {items.map(item => (
45          <div
46            key={item.id}
47            style={{
48              padding: '8px',
49              borderBottom: '1px solid #eee',
50              backgroundColor: item.id % 2 === 0 ? '#f9f9f9' : '#fff'
51            }}
52          >
53            <strong>{item.name}</strong> - {item.description}
54          </div>
55        ))}
56      </div>
57    </div>
58  );
59}
60
61function generateItems(count) {
62  return Array.from({ length: count }, (_, i) => ({
63    id: i,
64    name: `Element ${i + 1}`,
65    description: `Opis elementu numer ${i + 1}`
66  }));
67}

3. Współpraca z Suspense

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}
31
32// Komponent wykorzystujący zasób
33function UserProfile({ userId }) {
34  const userResource = createResource(
35    fetch(`/api/users/${userId}`).then(res => res.json())
36  );
37  
38  const user = userResource.read();
39  
40  return (
41    <div style={{ padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
42      <h2>{user.name}</h2>
43      <p>Email: {user.email}</p>
44      <p>ID: {user.id}</p>
45    </div>
46  );
47}
48
49// Główny komponent z useTransition
50function UsersList() {
51  const [selectedUserId, setSelectedUserId] = useState(1);
52  const [isPending, startTransition] = useTransition();
53  
54  const users = [
55    { id: 1, name: 'Anna Kowalska' },
56    { id: 2, name: 'Jan Nowak' },
57    { id: 3, name: 'Maria Wiśniewska' }
58  ];
59
60  const handleUserSelect = (userId) => {
61    startTransition(() => {
62      setSelectedUserId(userId);
63    });
64  };
65
66  return (
67    <div style={{ display: 'flex', gap: '20px' }}>
68      <div style={{ width: '200px' }}>
69        <h3>Wybierz użytkownika:</h3>
70        {users.map(user => (
71          <button
72            key={user.id}
73            onClick={() => handleUserSelect(user.id)}
74            style={{
75              display: 'block',
76              width: '100%',
77              margin: '5px 0',
78              padding: '10px',
79              backgroundColor: selectedUserId === user.id ? '#007bff' : '#f8f9fa',
80              color: selectedUserId === user.id ? 'white' : 'black',
81              border: '1px solid #ddd',
82              borderRadius: '4px',
83              cursor: 'pointer'
84            }}
85          >
86            {user.name}
87          </button>
88        ))}
89      </div>
90      
91      <div style={{ flex: 1 }}>
92        <Suspense
93          fallback={
94            <div style={{ 
95              padding: '20px', 
96              textAlign: 'center',
97              border: '1px dashed #ddd',
98              borderRadius: '8px'
99            }}>
100              {isPending ? 'Ładowanie nowego użytkownika...' : 'Ładowanie...'}
101            </div>
102          }
103        >
104          <UserProfile userId={selectedUserId} />
105        </Suspense>
106      </div>
107    </div>
108  );
109}

Zaawansowane wzorce z useTransition

1. Optymistyczne aktualizacje

1import { useTransition, useState, useOptimistic } 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  };
69
70  return (
71    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
72      <h2>Lista zadań</h2>
73      
74      <form
75        onSubmit={(e) => {
76          e.preventDefault();
77          const formData = new FormData(e.target);
78          const text = formData.get('text');
79          if (text) {
80            addTodo(text);
81            e.target.reset();
82          }
83        }}
84        style={{ marginBottom: '20px' }}
85      >
86        <input
87          name="text"
88            style={{ padding: '8px', marginRight: '8px', flex: 1 }}
89        />
90        <button
91          type="submit"
92          disabled={isPending}
93          style={{
94            padding: '8px 16px',
95            backgroundColor: '#007bff',
96            color: 'white',
97            border: 'none',
98            borderRadius: '4px',
99            cursor: isPending ? 'not-allowed' : 'pointer'
100          }}
101        >
102          {isPending ? 'Dodawanie...' : 'Dodaj'}
103        </button>
104      </form>
105
106      <ul style={{ listStyle: 'none', padding: 0 }}>
107        {todos.map(todo => (
108          <li
109            key={todo.id}
110            style={{
111              display: 'flex',
112              alignItems: 'center',
113              padding: '10px',
114              marginBottom: '5px',
115              backgroundColor: '#f8f9fa',
116              borderRadius: '4px',
117              opacity: todo.isPending ? 0.6 : 1
118            }}
119          >
120            <input
121              type="checkbox"
122              checked={todo.completed}
123              onChange={() => toggleTodo(todo.id)}
124              disabled={todo.isPending}
125              style={{ marginRight: '10px' }}
126            />
127            <span
128              style={{
129                textDecoration: todo.completed ? 'line-through' : 'none',
130                flex: 1
131              }}
132            >
133              {todo.text}
134            </span>
135            {todo.isPending && (
136              <span style={{ color: '#666', fontSize: '12px' }}>
137                Aktualizowanie...
138              </span>
139            )}
140          </li>
141        ))}
142      </ul>
143    </div>
144  );
145}

2. Debouncing z useTransition

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]);
29
30  return (
31    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
32      <h2>Wyszukiwarka z debouncing</h2>
33      
34      <div style={{ position: 'relative', marginBottom: '20px' }}>
35        <input
36          value={query}
37          onChange={(e) => setQuery(e.target.value)}
38            style={{
39            width: '100%',
40            padding: '12px',
41            fontSize: '16px',
42            border: '2px solid #ddd',
43            borderRadius: '8px',
44            outline: 'none'
45          }}
46        />
47        {isPending && (
48          <div style={{
49            position: 'absolute',
50            right: '10px',
51            top: '50%',
52            transform: 'translateY(-50%)',
53            color: '#666'
54          }}>
55            🔍 Wyszukiwanie...
56          </div>
57        )}
58      </div>
59
60      <div>
61        <p style={{ color: '#666', margin: '10px 0' }}>
62          Znaleziono {results.length} wyników
63          {debouncedQuery && ` dla "${debouncedQuery}"`}
64        </p>
65        
66        <div style={{ maxHeight: '400px', overflowY: 'auto' }}>
67          {results.map(result => (
68            <div
69              key={result.id}
70              style={{
71                padding: '12px',
72                marginBottom: '8px',
73                backgroundColor: '#f8f9fa',
74                border: '1px solid #e9ecef',
75                borderRadius: '6px'
76              }}
77            >
78              <h4 style={{ margin: '0 0 5px 0', color: '#007bff' }}>
79                {highlightMatch(result.title, debouncedQuery)}
80              </h4>
81              <p style={{ margin: 0, color: '#666', fontSize: '14px' }}>
82                {highlightMatch(result.description, debouncedQuery)}
83              </p>
84            </div>
85          ))}
86        </div>
87      </div>
88    </div>
89  );
90}
91
92function performSearch(query) {
93  const items = [
94    { id: 1, title: 'React Hooks', description: 'Nowoczesny sposób na zarządzanie stanem w React' },
95    { id: 2, title: 'JavaScript ES6+', description: 'Najnowsze funkcje JavaScript' },
96    { id: 3, title: 'TypeScript Guide', description: 'Kompletny przewodnik po TypeScript' },
97    { id: 4, title: 'Node.js Backend', description: 'Tworzenie serwerów z Node.js' },
98    { id: 5, title: 'CSS Grid Layout', description: 'Zaawansowane układy CSS' }
99  ];
100
101  return items.filter(item =>
102    item.title.toLowerCase().includes(query.toLowerCase()) ||
103    item.description.toLowerCase().includes(query.toLowerCase())
104  );
105}
106
107function highlightMatch(text, query) {
108  if (!query) return text;
109  
110  const regex = new RegExp(`(${query})`, 'gi');
111  const parts = text.split(regex);
112  
113  return parts.map((part, index) =>
114    part.toLowerCase() === query.toLowerCase() ? (
115      <mark key={index} style={{ backgroundColor: '#ffeb3b', padding: '2px' }}>
116        {part}
117      </mark>
118    ) : part
119  );
120}

3. Koordinacja multiple transitions

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;
58
59  return (
60    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
61      <h2>Multiple Transitions Demo</h2>
62      
63      {/* Tab Navigation */}
64      <div style={{ marginBottom: '20px', borderBottom: '1px solid #ddd' }}>
65        {['users', 'products'].map(tab => (
66          <button
67            key={tab}
68            onClick={() => handleTabChange(tab)}
69            disabled={tabPending}
70            style={{
71              padding: '10px 20px',
72              marginRight: '10px',
73              backgroundColor: activeTab === tab ? '#007bff' : 'transparent',
74              color: activeTab === tab ? 'white' : '#007bff',
75              border: `2px solid ${activeTab === tab ? '#007bff' : '#ddd'}`,
76              borderBottom: 'none',
77              borderRadius: '8px 8px 0 0',
78              cursor: tabPending ? 'not-allowed' : 'pointer',
79              opacity: tabPending ? 0.6 : 1
80            }}
81          >
82            {tab.charAt(0).toUpperCase() + tab.slice(1)}
83            {tabPending && ' ⏳'}
84          </button>
85        ))}
86      </div>
87
88      {/* Controls */}
89      <div style={{ 
90        display: 'flex', 
91        gap: '15px', 
92        marginBottom: '20px',
93        alignItems: 'center'
94      }}>
95        <input
96          value={filter}
97          onChange={(e) => handleFilterChange(e.target.value)}
98            style={{
99            padding: '8px',
100            border: '1px solid #ddd',
101            borderRadius: '4px',
102            flex: 1
103          }}
104        />
105        
106        <select
107          value={sortBy}
108          onChange={(e) => handleSortChange(e.target.value)}
109          style={{
110            padding: '8px',
111            border: '1px solid #ddd',
112            borderRadius: '4px'
113          }}
114        >
115          <option value="name">Sortuj po nazwie</option>
116          {activeTab === 'products' && <option value="price">Sortuj po cenie</option>}
117        </select>
118
119        {isPending && (
120          <div style={{ color: '#666', fontSize: '14px' }}>
121            Aktualizowanie...
122          </div>
123        )}
124      </div>
125
126      {/* Data Display */}
127      <div style={{ 
128        minHeight: '200px',
129        opacity: isPending ? 0.7 : 1,
130        transition: 'opacity 0.2s'
131      }}>
132        {filteredAndSortedData.length === 0 ? (
133          <div style={{ 
134            textAlign: 'center', 
135            color: '#666', 
136            padding: '40px' 
137          }}>
138            Brak wyników
139          </div>
140        ) : (
141          <div style={{ display: 'grid', gap: '10px' }}>
142            {filteredAndSortedData.map(item => (
143              <div
144                key={item.id}
145                style={{
146                  padding: '15px',
147                  backgroundColor: '#f8f9fa',
148                  border: '1px solid #e9ecef',
149                  borderRadius: '6px'
150                }}
151              >
152                <h4 style={{ margin: '0 0 5px 0' }}>{item.name}</h4>
153                {activeTab === 'users' ? (
154                  <div>
155                    <p style={{ margin: '2px 0', color: '#666' }}>
156                      📧 {item.email}
157                    </p>
158                    <p style={{ margin: '2px 0', color: '#666' }}>
159                      👤 {item.role}
160                    </p>
161                  </div>
162                ) : (
163                  <div>
164                    <p style={{ margin: '2px 0', color: '#666' }}>
165                      💰 {item.price}166                    </p>
167                    <p style={{ margin: '2px 0', color: '#666' }}>
168                      📂 {item.category}
169                    </p>
170                  </div>
171                )}
172              </div>
173            ))}
174          </div>
175        )}
176      </div>
177    </div>
178  );
179}

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

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.

Przejdź do CodeWorlds