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

useTransition - nieblokujące aktualizacje UI

14 min czytania
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żą isPending i startTransition,
  • jak useTransition współ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}
31

Metoda 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}
15

Komponent 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  };
69

Zadanie 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]);
29

Debouncing 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}
62

Pole 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;
58

Zakł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?

Przydatne artykuły