Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
useOptimistic - optymistyczne aktualizacje UI
W tej lekcji4
Wiadomość do bazy na Marsie leci kilka minut, ale załoga nie chce patrzeć na kręcący się wskaźnik. Chce od razu zobaczyć swoją wiadomość na liście, z dopiskiem "wysyłanie". Jeśli transmisja się nie uda, wiadomość ma zniknąć. To właśnie optymistyczna aktualizacja: zakładamy sukces, a w razie porażki wracamy do prawdziwego stanu.
Hook useOptimistic pozwala na natychmiastowe aktualizowanie interfejsu użytkownika przed otrzymaniem odpowiedzi z serwera. To kluczowe narzędzie do tworzenia responsywnych aplikacji, które dają użytkownikowi wrażenie natychmiastowej reakcji.
Podstawy useOptimistic
1. Składnia i podstawowe użycie
Hook przyjmuje prawdziwy stan i funkcję aktualizującą, a zwraca stan optymistyczny oraz funkcję, która go zmienia. Najważniejsza zasada z react.dev: tę funkcję trzeba wywołać wewnątrz Akcji, czyli w startTransition albo w akcji formularza. Poza Akcją React wypisze ostrzeżenie, a zmiana mignie i od razu zniknie. Najpierw logika listy zadań:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function TodoList() {
4 const [todos, setTodos] = useState([
5 { id: 1, text: 'Nauczyć się React', completed: false },
6 { id: 2, text: 'Zbudować aplikację', completed: false }
7 ]);
8
9 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
10 todos,
11 // Funkcja aktualizująca stan optymistyczny
12 (currentTodos, newTodo) => [...currentTodos, newTodo]
13 );
14
15 async function handleAddTodo(text) {
16 const newTodo = {
17 id: Date.now(),
18 text,
19 completed: false,
20 pending: true // Oznaczenie jako pending
21 };
22
23 // Optymistyczna aktualizacja
24 addOptimisticTodo(newTodo);
25
26 try {
27 // Rzeczywiste wywołanie API
28 const response = await fetch('/api/todos', {
29 method: 'POST',
30 body: JSON.stringify({ text })
31 });
32
33 const savedTodo = await response.json();
34
35 // Aktualizacja rzeczywistego stanu
36 startTransition(() => {
37 setTodos(current => [...current, savedTodo]);
38 });
39 } catch (error) {
40 // W przypadku błędu stan optymistyczny zostanie automatycznie cofnięty
41 console.error('Błąd dodawania todo:', error);
42 }
43 }
44Optymistyczne zadanie istnieje tylko tak długo, jak trwa Akcja. Gdy się zakończy, React pokazuje zwykły stan todos. Jeśli zapis się udał, setTodos dodał już prawdziwe zadanie, a jeśli nie, stan jest taki jak przedtem, więc zmiana "cofa się" sama. Aktualizację po await owijamy w kolejne startTransition, tak jak w przykładach z dokumentacji. Oto widok:
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 text = e.target.todo.value;
9 if (text) {
10 startTransition(() => handleAddTodo(text));
11 e.target.reset();
12 }
13 }}
14 style={{ marginBottom: '20px' }}
15 >
16 <input
17 name="todo"
18 style={{
19 width: '70%',
20 padding: '8px',
21 marginRight: '10px',
22 border: '1px solid #ddd',
23 borderRadius: '4px'
24 }}
25 />
26 <button
27 type="submit"
28 style={{
29 padding: '8px 16px',
30 backgroundColor: '#007bff',
31 color: 'white',
32 border: 'none',
33 borderRadius: '4px',
34 cursor: 'pointer'
35 }}
36 >
37 Dodaj
38 </button>
39 </form>
40
41 <ul style={{ listStyle: 'none', padding: 0 }}>
42 {optimisticTodos.map(todo => (
43 <li
44 key={todo.id}
45 style={{
46 padding: '10px',
47 marginBottom: '5px',
48 backgroundColor: todo.pending ? '#f0f8ff' : '#f8f9fa',
49 border: '1px solid #ddd',
50 borderRadius: '4px',
51 opacity: todo.pending ? 0.7 : 1,
52 transition: 'all 0.3s'
53 }}
54 >
55 <input
56 type="checkbox"
57 checked={todo.completed}
58 readOnly
59 style={{ marginRight: '10px' }}
60 />
61 <span style={{
62 textDecoration: todo.completed ? 'line-through' : 'none'
63 }}>
64 {todo.text}
65 </span>
66 {todo.pending && (
67 <span style={{
68 marginLeft: '10px',
69 color: '#007bff',
70 fontSize: '12px'
71 }}>
72 (zapisywanie...)
73 </span>
74 )}
75 </li>
76 ))}
77 </ul>
78 </div>
79 );
80}Formularz wywołuje handleAddTodo wewnątrz startTransition, dzięki czemu optymistyczny wpis z dopiskiem "(zapisywanie...)" zostaje na ekranie aż do odpowiedzi serwera.
2. Optymistyczny przełącznik z obsługą błędów
Przycisk polubienia to klasyczny przypadek: reakcja ma być natychmiastowa, a serwer potwierdza ją w tle. Tutaj funkcja aktualizująca po prostu zwraca nową wartość:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function LikeButton({ postId, initialLikes, initialIsLiked }) {
4 const [likes, setLikes] = useState(initialLikes);
5 const [isLiked, setIsLiked] = useState(initialIsLiked);
6
7 const [optimisticLikes, updateOptimisticLikes] = useOptimistic(
8 { count: likes, isLiked },
9 (current, optimisticValue) => optimisticValue
10 );
11
12 async function handleLikeToggle() {
13 const newIsLiked = !optimisticLikes.isLiked;
14 const newCount = optimisticLikes.count + (newIsLiked ? 1 : -1);
15
16 // Optymistyczna aktualizacja
17 updateOptimisticLikes({
18 count: newCount,
19 isLiked: newIsLiked
20 });
21
22 try {
23 const response = await fetch(`/api/posts/${postId}/like`, {
24 method: newIsLiked ? 'POST' : 'DELETE'
25 });
26
27 if (!response.ok) {
28 throw new Error('Błąd aktualizacji');
29 }
30
31 const data = await response.json();
32
33 // Aktualizacja rzeczywistego stanu
34 setLikes(data.likes);
35 setIsLiked(data.isLiked);
36 } catch (error) {
37 // Stan optymistyczny zostanie automatycznie cofnięty
38 alert('Nie udało się zaktualizować polubienia');
39 }
40 }
41
42 return (
43 <button
44 onClick={() => startTransition(handleLikeToggle)}
45 style={{
46 display: 'flex',
47 alignItems: 'center',
48 gap: '8px',
49 padding: '8px 16px',
50 backgroundColor: optimisticLikes.isLiked ? '#dc3545' : '#fff',
51 color: optimisticLikes.isLiked ? '#fff' : '#dc3545',
52 border: '1px solid #dc3545',
53 borderRadius: '20px',
54 cursor: 'pointer',
55 transition: 'all 0.3s',
56 fontSize: '16px'
57 }}
58 >
59 <span>{optimisticLikes.isLiked ? 'Polubione' : 'Lubię to'}</span>
60 <span>{optimisticLikes.count}</span>
61 </button>
62 );
63}
64
65// Przykład użycia w komponencie posta
66function Post({ post }) {
67 return (
68 <article style={{
69 padding: '20px',
70 marginBottom: '20px',
71 backgroundColor: '#fff',
72 borderRadius: '8px',
73 boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
74 }}>
75 <h3>{post.title}</h3>
76 <p>{post.content}</p>
77
78 <div style={{
79 display: 'flex',
80 justifyContent: 'space-between',
81 alignItems: 'center',
82 marginTop: '15px'
83 }}>
84 <LikeButton
85 postId={post.id}
86 initialLikes={post.likes}
87 initialIsLiked={post.isLiked}
88 />
89
90 <span style={{ color: '#6c757d', fontSize: '14px' }}>
91 {new Date(post.createdAt).toLocaleDateString('pl-PL')}
92 </span>
93 </div>
94 </article>
95 );
96}Kliknięcie uruchamia handleLikeToggle w startTransition. Gdy serwer zwróci błąd, blok catch pokazuje komunikat, a licznik wraca do wartości z likes, bo prawdziwy stan się nie zmienił.
3. Optymistyczne sortowanie i filtrowanie
Funkcja aktualizująca działa jak reducer: dostaje obecny stan i opis zmiany. Dzięki temu jedno wywołanie obsługuje i checkbox, i zmianę priorytetu:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticTaskList() {
4 const [tasks, setTasks] = useState([
5 { id: 1, title: 'Zadanie 1', priority: 'low', completed: false },
6 { id: 2, title: 'Zadanie 2', priority: 'high', completed: false },
7 { id: 3, title: 'Zadanie 3', priority: 'medium', completed: true }
8 ]);
9
10 const [optimisticTasks, updateOptimisticTask] = useOptimistic(
11 tasks,
12 (currentTasks, { id, updates }) =>
13 currentTasks.map(task =>
14 task.id === id ? { ...task, ...updates, updating: true } : task
15 )
16 );
17
18 async function handleTaskUpdate(taskId, updates) {
19 // Optymistyczna aktualizacja
20 updateOptimisticTask({ id: taskId, updates });
21
22 try {
23 const response = await fetch(`/api/tasks/${taskId}`, {
24 method: 'PATCH',
25 headers: { 'Content-Type': 'application/json' },
26 body: JSON.stringify(updates)
27 });
28
29 const updatedTask = await response.json();
30
31 // Aktualizacja rzeczywistego stanu
32 setTasks(current =>
33 current.map(task => task.id === taskId ? updatedTask : task)
34 );
35 } catch (error) {
36 console.error('Błąd aktualizacji zadania:', error);
37 }
38 }
39
40 const priorityColors = {
41 low: '#28a745',
42 medium: '#ffc107',
43 high: '#dc3545'
44 };
45React uruchamia tę funkcję ponownie, jeśli prawdziwy tasks zmieni się w trakcie Akcji, więc zmiana nakłada się zawsze na aktualne dane. Oto widok ze statystykami:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Zarządzanie zadaniami</h2>
4
5 <div style={{ marginBottom: '20px' }}>
6 <h3>Statystyki:</h3>
7 <div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
8 <span>
9 Ukończone: {optimisticTasks.filter(t => t.completed).length}
10 </span>
11 <span>
12 W toku: {optimisticTasks.filter(t => !t.completed).length}
13 </span>
14 <span>
15 Wysokie priorytety: {optimisticTasks.filter(t => t.priority === 'high').length}
16 </span>
17 </div>
18 </div>
19
20 <div style={{ display: 'grid', gap: '10px' }}>
21 {optimisticTasks.map(task => (
22 <div
23 key={task.id}
24 style={{
25 padding: '15px',
26 backgroundColor: task.updating ? '#f0f8ff' : '#fff',
27 border: '1px solid #ddd',
28 borderRadius: '8px',
29 opacity: task.updating ? 0.8 : 1,
30 transition: 'all 0.3s'
31 }}
32 >
33 <div style={{
34 display: 'flex',
35 justifyContent: 'space-between',
36 alignItems: 'center'
37 }}>
38 <div style={{ flex: 1 }}>
39 <input
40 type="checkbox"
41 checked={task.completed}
42 onChange={(e) =>
43 startTransition(() => handleTaskUpdate(task.id, { completed: e.target.checked }))
44 }
45 style={{ marginRight: '10px' }}
46 />
47 <span style={{
48 textDecoration: task.completed ? 'line-through' : 'none',
49 color: task.completed ? '#6c757d' : '#000'
50 }}>
51 {task.title}
52 </span>
53 </div>
54
55 <select
56 value={task.priority}
57 onChange={(e) =>
58 startTransition(() => handleTaskUpdate(task.id, { priority: e.target.value }))
59 }
60 style={{
61 padding: '4px 8px',
62 border: `1px solid ${priorityColors[task.priority]}`,
63 borderRadius: '4px',
64 color: priorityColors[task.priority],
65 backgroundColor: 'transparent',
66 cursor: 'pointer'
67 }}
68 >
69 <option value="low">Niski</option>
70 <option value="medium">Średni</option>
71 <option value="high">Wysoki</option>
72 </select>
73
74 {task.updating && (
75 <span style={{
76 marginLeft: '10px',
77 color: '#007bff',
78 fontSize: '12px'
79 }}>
80 Zapisywanie...
81 </span>
82 )}
83 </div>
84 </div>
85 ))}
86 </div>
87 </div>
88 );
89}Statystyki liczymy z optimisticTasks, więc liczniki zmieniają się razem z checkboxem, bez czekania na serwer.
Zaawansowane wzorce z useOptimistic
1. Optymistyczne usuwanie z animacją
Tym razem stan optymistyczny nie usuwa elementu, tylko oznacza go flagą deleting, co daje czas na animację:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticDeleteList() {
4 const [items, setItems] = useState([
5 { id: 1, name: 'Element 1', createdAt: new Date() },
6 { id: 2, name: 'Element 2', createdAt: new Date() },
7 { id: 3, name: 'Element 3', createdAt: new Date() }
8 ]);
9
10 const [optimisticItems, removeOptimisticItem] = useOptimistic(
11 items,
12 (currentItems, removedId) =>
13 currentItems.map(item =>
14 item.id === removedId ? { ...item, deleting: true } : item
15 )
16 );
17
18 async function handleDelete(itemId) {
19 // Optymistyczne oznaczenie jako usuwane
20 removeOptimisticItem(itemId);
21
22 // Animacja przed usunięciem
23 await new Promise(resolve => setTimeout(resolve, 300));
24
25 try {
26 const response = await fetch(`/api/items/${itemId}`, {
27 method: 'DELETE'
28 });
29
30 if (!response.ok) {
31 throw new Error('Błąd usuwania');
32 }
33
34 // Rzeczywiste usunięcie ze stanu
35 setItems(current => current.filter(item => item.id !== itemId));
36 } catch (error) {
37 // Cofnięcie optymistycznej zmiany
38 alert('Nie udało się usunąć elementu');
39 }
40 }
41
42 return (
43 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
44 <h2>Lista z optymistycznym usuwaniem</h2>
45
46 <div style={{ display: 'grid', gap: '10px' }}>
47 {optimisticItems
48 .map(item => (
49 <div
50 key={item.id}
51 style={{
52 padding: '15px',
53 backgroundColor: '#fff',
54 border: '1px solid #ddd',
55 borderRadius: '8px',
56 display: 'flex',
57 justifyContent: 'space-between',
58 alignItems: 'center',
59 opacity: item.deleting ? 0 : 1,
60 transform: item.deleting ? 'translateX(-100%)' : 'translateX(0)',
61 transition: 'all 0.3s ease-out',
62 overflow: 'hidden'
63 }}
64 >
65 <div>
66 <h4 style={{ margin: '0 0 5px 0' }}>{item.name}</h4>
67 <small style={{ color: '#6c757d' }}>
68 Utworzono: {item.createdAt.toLocaleString('pl-PL')}
69 </small>
70 </div>
71
72 <button
73 onClick={() => startTransition(() => handleDelete(item.id))}
74 disabled={item.deleting}
75 style={{
76 padding: '5px 10px',
77 backgroundColor: '#dc3545',
78 color: 'white',
79 border: 'none',
80 borderRadius: '4px',
81 cursor: item.deleting ? 'not-allowed' : 'pointer',
82 opacity: item.deleting ? 0.5 : 1
83 }}
84 >
85 {item.deleting ? 'Usuwanie...' : 'Usuń'}
86 </button>
87 </div>
88 ))}
89 </div>
90 </div>
91 );
92}Opóźnienie animacji jest teraz zwykłym await wewnątrz Akcji. Gdyby zapytanie siedziało w setTimeout, Akcja skończyłaby się od razu i flaga deleting zniknęłaby przed usunięciem.
2. Optymistyczna współpraca w czasie rzeczywistym
Edytor współdzielony łączy dwa stany optymistyczne z połączeniem WebSocket. Traktuj go jako szkic architektury:
1import { useOptimistic, useState, useEffect, startTransition } from 'react';
2
3function CollaborativeEditor({ documentId, userId }) {
4 const [content, setContent] = useState('');
5 const [collaborators, setCollaborators] = useState([]);
6
7 const [optimisticContent, updateOptimisticContent] = useOptimistic(
8 content,
9 (_, newContent) => newContent
10 );
11
12 const [optimisticCollaborators, updateOptimisticCollaborators] = useOptimistic(
13 collaborators,
14 (currentCollaborators, update) => {
15 if (update.type === 'cursor') {
16 return currentCollaborators.map(c =>
17 c.id === update.userId ? { ...c, cursor: update.position } : c
18 );
19 }
20 return currentCollaborators;
21 }
22 );
23
24 // WebSocket lub inne połączenie real-time
25 useEffect(() => {
26 const ws = new WebSocket(`ws://localhost:8080/doc/${documentId}`);
27
28 ws.onmessage = (event) => {
29 const data = JSON.parse(event.data);
30
31 if (data.type === 'content-update' && data.userId !== userId) {
32 setContent(data.content);
33 } else if (data.type === 'cursor-update') {
34 setCollaborators(data.collaborators);
35 }
36 };
37
38 return () => ws.close();
39 }, [documentId, userId]);
40
41 async function handleContentChange(newContent) {
42 // Optymistyczna aktualizacja
43 updateOptimisticContent(newContent);
44
45 try {
46 await fetch(`/api/documents/${documentId}`, {
47 method: 'PATCH',
48 headers: { 'Content-Type': 'application/json' },
49 body: JSON.stringify({ content: newContent, userId })
50 });
51
52 setContent(newContent);
53 } catch (error) {
54 console.error('Błąd zapisywania:', error);
55 }
56 }
57
58 function handleCursorMove(position) {
59 updateOptimisticCollaborators({
60 type: 'cursor',
61 userId,
62 position
63 });
64
65 // Wyślij aktualizację kursora przez WebSocket
66 // ws.send(JSON.stringify({ type: 'cursor', position }));
67 }
68
69 return (
70 <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
71 <h2>Edytor współdzielony</h2>
72
73 <div style={{ marginBottom: '10px' }}>
74 <h4>Aktywni użytkownicy:</h4>
75 <div style={{ display: 'flex', gap: '10px' }}>
76 {optimisticCollaborators.map(collaborator => (
77 <div
78 key={collaborator.id}
79 style={{
80 padding: '5px 10px',
81 backgroundColor: collaborator.color,
82 color: 'white',
83 borderRadius: '15px',
84 fontSize: '12px'
85 }}
86 >
87 {collaborator.name}
88 {collaborator.cursor && (
89 <span style={{ marginLeft: '5px' }}>
90 (linia {collaborator.cursor.line})
91 </span>
92 )}
93 </div>
94 ))}
95 </div>
96 </div>
97
98 <textarea
99 value={optimisticContent}
100 onChange={(e) => startTransition(() => handleContentChange(e.target.value))}
101 onSelect={(e) => {
102 const position = {
103 line: e.target.value.substring(0, e.target.selectionStart).split('\n').length,
104 column: e.target.selectionStart
105 };
106 startTransition(() => handleCursorMove(position));
107 }}
108 style={{
109 width: '100%',
110 height: '400px',
111 padding: '15px',
112 border: '1px solid #ddd',
113 borderRadius: '8px',
114 fontSize: '16px',
115 fontFamily: 'monospace'
116 }}
117 />
118
119 <div style={{
120 marginTop: '10px',
121 fontSize: '12px',
122 color: '#6c757d'
123 }}>
124 Ostatnia synchronizacja: {new Date().toLocaleTimeString('pl-PL')}
125 </div>
126 </div>
127 );
128}Kursor reaguje na zdarzenie onSelect, bo React nie ma zdarzenia onSelectionChange dla pola tekstowego. Dwie przestrogi: react.dev odradza sterowanie polem tekstowym przez przejścia, więc treść wpisywaną przez użytkownika lepiej trzymać w zwykłym stanie. Pozycja kursora z kolei nie czeka na serwer, dlatego zwykły useState pasuje do niej lepiej niż stan optymistyczny.
3. Wysyłanie paczkami z kolejką
Czat może wysyłać wiadomości paczkami po pięć. Najpierw kolejka i jej przetwarzanie:
1import { useOptimistic, useState, useRef, startTransition } from 'react';
2
3function BatchedUpdatesDemo() {
4 const [messages, setMessages] = useState([]);
5 const updateQueue = useRef([]);
6 const isProcessing = useRef(false);
7
8 const [optimisticMessages, addOptimisticMessage] = useOptimistic(
9 messages,
10 (currentMessages, newMessage) => [...currentMessages, newMessage]
11 );
12
13 async function processBatch() {
14 if (isProcessing.current || updateQueue.current.length === 0) return;
15
16 isProcessing.current = true;
17 const batch = updateQueue.current.splice(0, 5); // Max 5 na raz
18
19 try {
20 const response = await fetch('/api/messages/batch', {
21 method: 'POST',
22 headers: { 'Content-Type': 'application/json' },
23 body: JSON.stringify({ messages: batch })
24 });
25
26 const savedMessages = await response.json();
27
28 setMessages(current => [
29 ...current,
30 ...savedMessages.map(msg => ({ ...msg, sent: true }))
31 ]);
32 } catch (error) {
33 console.error('Błąd wysyłania batch:', error);
34 // Przywróć do kolejki
35 updateQueue.current.unshift(...batch);
36 } finally {
37 isProcessing.current = false;
38 // Przetwórz następną partię jeśli są
39 if (updateQueue.current.length > 0) {
40 setTimeout(processBatch, 100);
41 }
42 }
43 }
44
45 async function sendMessage(text) {
46 const newMessage = {
47 id: Date.now(),
48 text,
49 timestamp: new Date(),
50 sent: false,
51 pending: true
52 };
53
54 // Optymistyczna aktualizacja
55 addOptimisticMessage(newMessage);
56
57 // Dodaj do kolejki
58 updateQueue.current.push(newMessage);
59
60 // Rozpocznij przetwarzanie jeśli nie jest aktywne
61 await processBatch();
62 }
63useRef trzyma kolejkę poza stanem, więc jej zmiany nie wywołują renderu. Teraz wysyłanie i widok:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Chat z batch updates</h2>
4
5 <div style={{
6 height: '400px',
7 overflowY: 'auto',
8 border: '1px solid #ddd',
9 borderRadius: '8px',
10 padding: '15px',
11 marginBottom: '15px',
12 backgroundColor: '#f8f9fa'
13 }}>
14 {optimisticMessages.map(message => (
15 <div
16 key={message.id}
17 style={{
18 marginBottom: '10px',
19 padding: '10px',
20 backgroundColor: message.pending ? '#e3f2fd' : '#fff',
21 borderRadius: '8px',
22 opacity: message.pending ? 0.8 : 1,
23 transition: 'all 0.3s'
24 }}
25 >
26 <div style={{ marginBottom: '5px' }}>{message.text}</div>
27 <div style={{
28 fontSize: '12px',
29 color: '#6c757d',
30 display: 'flex',
31 justifyContent: 'space-between'
32 }}>
33 <span>{message.timestamp.toLocaleTimeString('pl-PL')}</span>
34 <span>
35 {message.pending ? 'Wysyłanie...' : 'Wysłane'}
36 </span>
37 </div>
38 </div>
39 ))}
40 </div>
41
42 <form
43 onSubmit={(e) => {
44 e.preventDefault();
45 const input = e.target.message;
46 if (input.value) {
47 startTransition(() => sendMessage(input.value));
48 input.value = '';
49 }
50 }}
51 style={{ display: 'flex', gap: '10px' }}
52 >
53 <input
54 name="message"
55 style={{
56 flex: 1,
57 padding: '10px',
58 border: '1px solid #ddd',
59 borderRadius: '4px'
60 }}
61 />
62 <button
63 type="submit"
64 style={{
65 padding: '10px 20px',
66 backgroundColor: '#007bff',
67 color: 'white',
68 border: 'none',
69 borderRadius: '4px',
70 cursor: 'pointer'
71 }}
72 >
73 Wyślij
74 </button>
75 </form>
76
77 <div style={{
78 marginTop: '10px',
79 fontSize: '12px',
80 color: '#6c757d'
81 }}>
82 W kolejce: {updateQueue.current.length} |
83 Przetwarzanie: {isProcessing.current ? 'Tak' : 'Nie'}
84 </div>
85 </div>
86 );
87}sendMessage czeka na processBatch, więc wiadomość zostaje optymistyczna do końca wysyłki. Uwaga: gdy paczka jest już w drodze, processBatch wraca od razu, a kolejna wiadomość może zniknąć przed wysłaniem. Licznik kolejki czyta też ref, więc nie odświeża się sam.
Praktyczne zastosowania useOptimistic
1. System komentarzy z reakcjami
Jeden reducer obsługuje trzy typy zmian: dodanie komentarza, reakcję i odpowiedź. Najpierw logika:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function CommentSystem({ postId }) {
4 const [comments, setComments] = useState([]);
5
6 const [optimisticComments, updateOptimisticComment] = useOptimistic(
7 comments,
8 (currentComments, action) => {
9 switch (action.type) {
10 case 'add':
11 return [...currentComments, action.comment];
12 case 'react':
13 return currentComments.map(comment =>
14 comment.id === action.commentId
15 ? {
16 ...comment,
17 reactions: {
18 ...comment.reactions,
19 [action.reaction]: (comment.reactions[action.reaction] || 0) + 1
20 },
21 userReacted: action.reaction
22 }
23 : comment
24 );
25 case 'reply':
26 return currentComments.map(comment =>
27 comment.id === action.parentId
28 ? {
29 ...comment,
30 replies: [...(comment.replies || []), action.reply]
31 }
32 : comment
33 );
34 default:
35 return currentComments;
36 }
37 }
38 );
39
40 async function addComment(text) {
41 const newComment = {
42 id: Date.now(),
43 text,
44 author: 'Ty',
45 timestamp: new Date(),
46 reactions: {},
47 replies: [],
48 pending: true
49 };
50
51 updateOptimisticComment({ type: 'add', comment: newComment });
52
53 try {
54 const response = await fetch(`/api/posts/${postId}/comments`, {
55 method: 'POST',
56 headers: { 'Content-Type': 'application/json' },
57 body: JSON.stringify({ text })
58 });
59
60 const savedComment = await response.json();
61 setComments(current => [...current, savedComment]);
62 } catch (error) {
63 console.error('Błąd dodawania komentarza:', error);
64 }
65 }
66
67 async function reactToComment(commentId, reaction) {
68 updateOptimisticComment({
69 type: 'react',
70 commentId,
71 reaction
72 });
73
74 try {
75 await fetch(`/api/comments/${commentId}/react`, {
76 method: 'POST',
77 headers: { 'Content-Type': 'application/json' },
78 body: JSON.stringify({ reaction })
79 });
80
81 // Zaktualizuj rzeczywisty stan
82 const response = await fetch(`/api/posts/${postId}/comments`);
83 const updatedComments = await response.json();
84 setComments(updatedComments);
85 } catch (error) {
86 console.error('Błąd reakcji:', error);
87 }
88 }
89
90 const reactionLabels = {
91 like: 'Lubię',
92 love: 'Super',
93 laugh: 'Haha',
94 wow: 'Wow',
95 sad: 'Smutne'
96 };
97Każda zmiana przechodzi przez updateOptimisticComment z polem type, tak jak akcje w useReducer. Teraz widok:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h3>Komentarze</h3>
4
5 <form
6 onSubmit={(e) => {
7 e.preventDefault();
8 const text = e.target.comment.value;
9 if (text) {
10 startTransition(() => addComment(text));
11 e.target.reset();
12 }
13 }}
14 style={{ marginBottom: '20px' }}
15 >
16 <textarea
17 name="comment"
18 rows="3"
19 style={{
20 width: '100%',
21 padding: '10px',
22 border: '1px solid #ddd',
23 borderRadius: '4px',
24 marginBottom: '10px'
25 }}
26 />
27 <button
28 type="submit"
29 style={{
30 padding: '8px 16px',
31 backgroundColor: '#007bff',
32 color: 'white',
33 border: 'none',
34 borderRadius: '4px',
35 cursor: 'pointer'
36 }}
37 >
38 Dodaj komentarz
39 </button>
40 </form>
41
42 <div style={{ display: 'grid', gap: '15px' }}>
43 {optimisticComments.map(comment => (
44 <div
45 key={comment.id}
46 style={{
47 padding: '15px',
48 backgroundColor: comment.pending ? '#f0f8ff' : '#fff',
49 border: '1px solid #ddd',
50 borderRadius: '8px',
51 opacity: comment.pending ? 0.8 : 1
52 }}
53 >
54 <div style={{ marginBottom: '10px' }}>
55 <strong>{comment.author}</strong>
56 <span style={{
57 marginLeft: '10px',
58 color: '#6c757d',
59 fontSize: '14px'
60 }}>
61 {comment.timestamp.toLocaleString('pl-PL')}
62 </span>
63 {comment.pending && (
64 <span style={{
65 marginLeft: '10px',
66 color: '#007bff',
67 fontSize: '12px'
68 }}>
69 (wysyłanie...)
70 </span>
71 )}
72 </div>
73
74 <p style={{ margin: '10px 0' }}>{comment.text}</p>
75
76 <div style={{ display: 'flex', gap: '10px' }}>
77 {Object.entries(reactionLabels).map(([reaction, label]) => (
78 <button
79 key={reaction}
80 onClick={() => startTransition(() => reactToComment(comment.id, reaction))}
81 style={{
82 padding: '4px 8px',
83 backgroundColor: comment.userReacted === reaction ? '#e3f2fd' : '#f8f9fa',
84 border: '1px solid #ddd',
85 borderRadius: '15px',
86 cursor: 'pointer',
87 fontSize: '14px',
88 display: 'flex',
89 alignItems: 'center',
90 gap: '4px'
91 }}
92 >
93 <span>{label}</span>
94 {comment.reactions[reaction] > 0 && (
95 <span>{comment.reactions[reaction]}</span>
96 )}
97 </button>
98 ))}
99 </div>
100
101 {comment.replies && comment.replies.length > 0 && (
102 <div style={{
103 marginTop: '15px',
104 marginLeft: '20px',
105 paddingLeft: '15px',
106 borderLeft: '2px solid #e9ecef'
107 }}>
108 {comment.replies.map(reply => (
109 <div key={reply.id} style={{ marginBottom: '10px' }}>
110 <strong>{reply.author}:</strong> {reply.text}
111 </div>
112 ))}
113 </div>
114 )}
115 </div>
116 ))}
117 </div>
118 </div>
119 );
120}Przyciski reakcji i formularz uruchamiają funkcje w startTransition, więc liczniki rosną natychmiast.
2. Drag & Drop z optymistycznym sortowaniem
Przeciąganie elementów to kolejny przypadek, w którym użytkownik nie może czekać na serwer:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function DragDropList() {
4 const [items, setItems] = useState([
5 { id: 1, text: 'Element 1', order: 0 },
6 { id: 2, text: 'Element 2', order: 1 },
7 { id: 3, text: 'Element 3', order: 2 },
8 { id: 4, text: 'Element 4', order: 3 }
9 ]);
10
11 const [draggedItem, setDraggedItem] = useState(null);
12
13 const [optimisticItems, reorderOptimistic] = useOptimistic(
14 items,
15 (currentItems, { fromIndex, toIndex }) => {
16 const newItems = [...currentItems];
17 const [removed] = newItems.splice(fromIndex, 1);
18 newItems.splice(toIndex, 0, removed);
19 return newItems.map((item, index) => ({ ...item, order: index }));
20 }
21 );
22
23 async function handleDrop(fromIndex, toIndex) {
24 if (fromIndex === toIndex) return;
25
26 // Optymistyczna aktualizacja
27 reorderOptimistic({ fromIndex, toIndex });
28
29 try {
30 const response = await fetch('/api/items/reorder', {
31 method: 'POST',
32 headers: { 'Content-Type': 'application/json' },
33 body: JSON.stringify({ fromIndex, toIndex })
34 });
35
36 const reorderedItems = await response.json();
37 setItems(reorderedItems);
38 } catch (error) {
39 console.error('Błąd zmiany kolejności:', error);
40 }
41 }
42
43 return (
44 <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
45 <h2>Lista Drag & Drop</h2>
46
47 <div style={{
48 border: '1px solid #ddd',
49 borderRadius: '8px',
50 overflow: 'hidden'
51 }}>
52 {optimisticItems.map((item, index) => (
53 <div
54 key={item.id}
55 draggable
56 onDragStart={() => setDraggedItem({ item, index })}
57 onDragOver={(e) => e.preventDefault()}
58 onDrop={() => {
59 if (draggedItem) {
60 startTransition(() => handleDrop(draggedItem.index, index));
61 setDraggedItem(null);
62 }
63 }}
64 style={{
65 padding: '15px',
66 backgroundColor: '#fff',
67 borderBottom: '1px solid #e9ecef',
68 cursor: 'move',
69 display: 'flex',
70 alignItems: 'center',
71 transition: 'all 0.3s',
72 opacity: draggedItem?.item.id === item.id ? 0.5 : 1
73 }}
74 >
75 <span style={{
76 marginRight: '15px',
77 fontSize: '20px',
78 color: '#6c757d'
79 }}>
80 ≡
81 </span>
82 <span style={{ flex: 1 }}>{item.text}</span>
83 <span style={{
84 color: '#6c757d',
85 fontSize: '14px'
86 }}>
87 #{item.order + 1}
88 </span>
89 </div>
90 ))}
91 </div>
92
93 <p style={{
94 marginTop: '15px',
95 fontSize: '14px',
96 color: '#6c757d',
97 textAlign: 'center'
98 }}>
99 Przeciągnij elementy, aby zmienić kolejność
100 </p>
101 </div>
102 );
103}Nowa kolejność pojawia się od razu po upuszczeniu, a serwer tylko ją potwierdza.
Najlepsze praktyki z useOptimistic
1. Struktura danych
Oznaczaj dane optymistyczne, żeby interfejs mógł je wyróżnić:
1// DOBRZE - oznaczanie stanu optymistycznego
2const optimisticItem = {
3 ...originalItem,
4 optimistic: true, // lub pending: true
5 optimisticId: Date.now() // do identyfikacji
6};
7
8// DOBRZE - zachowanie oryginalnych danych
9const [optimistic, update] = useOptimistic(
10 realData,
11 (current, change) => ({
12 ...current,
13 ...change,
14 _original: current // zachowaj oryginał
15 })
16);
17
18// ŹLE - brak oznaczenia stanu
19const badOptimistic = {
20 ...originalItem
21 // Brak sposobu na odróżnienie od rzeczywistych danych
22};Flaga pending lub optimistic pozwala pokazać "wysyłanie" i zablokować akcje na niepotwierdzonych elementach.
2. Obsługa błędów
Ponawianie z wykładniczym odstępem pasuje do chwilowych awarii sieci:
1// Strategia retry z exponential backoff
2async function optimisticUpdateWithRetry(action, maxRetries = 3) {
3 let lastError;
4
5 for (let i = 0; i < maxRetries; i++) {
6 try {
7 const result = await action();
8 return result;
9 } catch (error) {
10 lastError = error;
11
12 if (i < maxRetries - 1) {
13 // Exponential backoff
14 await new Promise(resolve =>
15 setTimeout(resolve, Math.pow(2, i) * 1000)
16 );
17 }
18 }
19 }
20
21 // Pokaż użytkownikowi błąd
22 showErrorNotification('Operacja nie powiodła się. Spróbuj ponownie.');
23 throw lastError;
24}Pamiętaj, że stan optymistyczny trwa przez całe ponawianie, bo cała funkcja działa w jednej Akcji.
3. Wydajność
Na koniec dwa wzorce wydajnościowe:
1// DOBRZE - batch updates
2const [optimistic, batchUpdate] = useOptimistic(
3 items,
4 (current, updates) => {
5 // Przetwórz wiele aktualizacji na raz
6 return updates.reduce((acc, update) => {
7 // Zastosuj każdą aktualizację
8 return applyUpdate(acc, update);
9 }, current);
10 }
11);
12
13// Użycie
14function handleMultipleUpdates(updates) {
15 batchUpdate(updates); // Jedna aktualizacja zamiast wielu
16}
17
18// DOBRZE - memoizacja ciężkich obliczeń
19const processedOptimisticData = useMemo(() => {
20 return expensiveProcessing(optimisticData);
21}, [optimisticData]);Jedno wywołanie z tablicą zmian daje jeden przebieg reducera zamiast wielu.
Hook useOptimistic to klucz do tworzenia responsywnych aplikacji, które dają użytkownikowi natychmiastową informację zwrotną. Prawidłowe używanie tego hooka może znacząco poprawić postrzeganą wydajność aplikacji.
Moja rada: najprościej jest używać useOptimistic z akcją formularza, bo wtedy React sam owija ją w przejście. W projekcie tego modułu połączysz ten hook z useActionState. Pamiętaj: optymizm na mostku trwa tylko do raportu z Marsa, potem liczy się prawdziwy stan.
Kod do tej lekcji: App.jsx
1import { useState, useOptimistic, useTransition } from 'react';
2
3// Symulacja API: opóźnione odpowiedzi serwera
4function simulateAPI(action, data) {
5 return new Promise((resolve, reject) => {
6 setTimeout(() => {
7 // 10% szans na błąd
8 if (Math.random() < 0.1) {
9 reject(new Error(`Błąd serwera przy: ${action}`));
10 } else {
11 resolve({ success: true, data });
12 }
13 }, 1000 + Math.random() * 1500);
14 });
15}
16
17function MissionCard({ mission, onToggle, onDelete }) {
18 return (
19 <div style={{
20 padding: '14px 16px', marginBottom: '10px',
21 background: mission.sending
22 ? 'rgba(0, 212, 255, 0.06)'
23 : mission.completed
24 ? 'rgba(78, 205, 196, 0.08)'
25 : 'rgba(255, 255, 255, 0.03)',
26 borderRadius: '10px',
27 border: `1px solid ${mission.sending ? '#1a3a5c' : mission.completed ? '#2a5c3a' : '#1a2a3c'}`,
28 opacity: mission.sending ? 0.7 : 1,
29 transition: 'all 0.3s ease',
30 display: 'flex', alignItems: 'center', gap: '12px',
31 }}>
32 <button
33 onClick={() => onToggle(mission.id)}
34 disabled={mission.sending}
35 aria-pressed={mission.completed}
36 style={{
37 width: '28px', height: '28px', borderRadius: '50%',
38 border: `2px solid ${mission.completed ? '#4ecdc4' : '#778da9'}`,
39 background: mission.completed ? '#4ecdc4' : 'transparent',
40 cursor: mission.sending ? 'default' : 'pointer',
41 display: 'flex', alignItems: 'center', justifyContent: 'center',
42 color: '#fff', fontSize: '14px', flexShrink: 0,
43 }}
44 >
45 {mission.completed ? '√' : ''}
46 </button>
47
48 <div style={{ flex: 1 }}>
49 <div style={{
50 color: mission.completed ? '#4ecdc4' : '#e0e1dd',
51 textDecoration: mission.completed ? 'line-through' : 'none',
52 fontSize: '15px', fontWeight: 500,
53 }}>
54 {mission.name}
55 </div>
56 <div style={{ color: '#556677', fontSize: '12px', marginTop: '2px' }}>
57 {mission.priority}
58 </div>
59 </div>
60
61 {mission.sending && (
62 <span style={{ color: '#00d4ff', fontSize: '11px', fontStyle: 'italic' }}>
63 Synchronizacja...
64 </span>
65 )}
66
67 <button
68 onClick={() => onDelete(mission.id)}
69 disabled={mission.sending}
70 style={{
71 padding: '4px 8px', background: 'transparent',
72 border: '1px solid #5c1a1a', borderRadius: '4px',
73 color: '#ff6b6b', cursor: mission.sending ? 'default' : 'pointer',
74 fontSize: '12px',
75 }}
76 >
77 Usuń
78 </button>
79 </div>
80 );
81}
82
83export default function App() {
84 const [missions, setMissions] = useState([
85 { id: 1, name: 'Zbadaj orbitę Marsa', completed: false, priority: 'Wysoki' },
86 { id: 2, name: 'Napraw moduł komunikacyjny', completed: true, priority: 'Krytyczny' },
87 { id: 3, name: 'Zbierz próbki z Io', completed: false, priority: 'Średni' },
88 { id: 4, name: 'Skalibruj teleskop', completed: false, priority: 'Niski' },
89 ]);
90
91 const [newMission, setNewMission] = useState('');
92 const [error, setError] = useState(null);
93 const [isPending, startTransition] = useTransition();
94
95 // useOptimistic - natychmiastowe aktualizacje UI
96 const [optimisticMissions, addOptimistic] = useOptimistic(
97 missions,
98 // Reduktor: (aktualny stan, zmiana optymistyczna) => nowy stan optymistyczny
99 (currentMissions, optimisticUpdate) => {
100 switch (optimisticUpdate.type) {
101 case 'toggle':
102 return currentMissions.map(m =>
103 m.id === optimisticUpdate.id
104 ? { ...m, completed: !m.completed, sending: true }
105 : m
106 );
107 case 'add':
108 return [...currentMissions, { ...optimisticUpdate.mission, sending: true }];
109 case 'delete':
110 return currentMissions.map(m =>
111 m.id === optimisticUpdate.id ? { ...m, sending: true } : m
112 );
113 default:
114 return currentMissions;
115 }
116 }
117 );
118
119 // Każda zmiana to Akcja w startTransition: stan optymistyczny trwa, dopóki funkcja asynchroniczna się nie skończy
120 function handleToggle(id) {
121 setError(null);
122 startTransition(async () => {
123 addOptimistic({ type: 'toggle', id });
124 try {
125 await simulateAPI('toggle', { id });
126 // Aktualizację po await owijamy w kolejne startTransition, jak radzi react.dev
127 startTransition(() => {
128 setMissions(prev =>
129 prev.map(m => m.id === id ? { ...m, completed: !m.completed } : m)
130 );
131 });
132 } catch (err) {
133 // Błąd: nic nie zmieniamy, stan optymistyczny zniknie sam po zakończeniu Akcji
134 setError(`Nie udało się zmienić statusu misji (ID: ${id})`);
135 }
136 });
137 }
138
139 function handleAdd(e) {
140 e.preventDefault();
141 const name = newMission.trim();
142 if (!name) return;
143 setError(null);
144 setNewMission('');
145
146 const tempMission = { id: Date.now(), name, completed: false, priority: 'Średni' };
147 startTransition(async () => {
148 addOptimistic({ type: 'add', mission: tempMission });
149 try {
150 await simulateAPI('add', tempMission);
151 startTransition(() => {
152 setMissions(prev => [...prev, tempMission]);
153 });
154 } catch (err) {
155 setError('Nie udało się dodać misji');
156 }
157 });
158 }
159
160 function handleDelete(id) {
161 setError(null);
162 startTransition(async () => {
163 addOptimistic({ type: 'delete', id });
164 try {
165 await simulateAPI('delete', { id });
166 startTransition(() => {
167 setMissions(prev => prev.filter(m => m.id !== id));
168 });
169 } catch (err) {
170 setError(`Nie udało się usunąć misji (ID: ${id})`);
171 }
172 });
173 }
174
175 const completed = optimisticMissions.filter(m => m.completed).length;
176 const total = optimisticMissions.length;
177
178 return (
179 <div style={{
180 minHeight: '100vh',
181 background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
182 padding: '24px', fontFamily: 'monospace',
183 }}>
184 <h1 style={{ color: '#00d4ff', textAlign: 'center', marginBottom: '4px' }}>
185 useOptimistic - Misje kosmiczne
186 </h1>
187 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px', fontSize: '13px' }}>
188 Zmiany widoczne natychmiast, synchronizacja w tle
189 </p>
190
191 <div style={{ maxWidth: '550px', margin: '0 auto' }}>
192 <div style={{
193 marginBottom: '20px', padding: '14px',
194 background: 'rgba(0, 212, 255, 0.06)', borderRadius: '10px',
195 }}>
196 <div style={{
197 display: 'flex', justifyContent: 'space-between',
198 marginBottom: '8px', fontSize: '13px', color: '#c0c8d0',
199 }}>
200 <span>Postęp misji {isPending && <em style={{ color: '#00d4ff' }}>(trwa synchronizacja)</em>}</span>
201 <span>{completed}/{total} ukończonych</span>
202 </div>
203 <div style={{
204 height: '8px', background: '#1a2a3c', borderRadius: '4px', overflow: 'hidden',
205 }}>
206 <div style={{
207 height: '100%', width: `${total > 0 ? (completed / total) * 100 : 0}%`,
208 background: 'linear-gradient(90deg, #00d4ff, #4ecdc4)',
209 borderRadius: '4px', transition: 'width 0.3s ease',
210 }} />
211 </div>
212 </div>
213
214 <form onSubmit={handleAdd} style={{
215 display: 'flex', gap: '8px', marginBottom: '20px',
216 }}>
217 <input
218 value={newMission}
219 onChange={e => setNewMission(e.target.value)}
220 placeholder="Nowa misja..."
221 style={{
222 flex: 1, padding: '10px 14px', background: '#0d1b2a',
223 border: '1px solid #1a3a5c', borderRadius: '8px',
224 color: '#e0e1dd', fontSize: '14px',
225 }}
226 />
227 <button type="submit" style={{
228 padding: '10px 20px', background: '#1a3a5c',
229 border: 'none', borderRadius: '8px', color: '#00d4ff',
230 cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap',
231 }}>
232 + Dodaj
233 </button>
234 </form>
235
236 {error && (
237 <div role="alert" style={{
238 padding: '10px 14px', marginBottom: '12px',
239 background: 'rgba(255, 107, 107, 0.1)', borderRadius: '8px',
240 border: '1px solid #5c1a1a', color: '#ff6b6b', fontSize: '13px',
241 }}>
242 {error}
243 </div>
244 )}
245
246 {optimisticMissions.map(mission => (
247 <MissionCard
248 key={mission.id}
249 mission={mission}
250 onToggle={handleToggle}
251 onDelete={handleDelete}
252 />
253 ))}
254
255 <div style={{
256 marginTop: '20px', padding: '14px',
257 background: 'rgba(255, 215, 0, 0.06)', borderRadius: '10px',
258 fontSize: '12px', color: '#c0c8d0', lineHeight: '1.7',
259 }}>
260 <strong style={{ color: '#ffd700' }}>Jak działa useOptimistic:</strong>
261 <ul style={{ margin: '8px 0', paddingLeft: '18px' }}>
262 <li>UI aktualizuje się natychmiast (optymistycznie)</li>
263 <li>W tle trwa synchronizacja z serwerem (1-2,5 s)</li>
264 <li>Przy błędzie (10% szans) stan optymistyczny znika i wraca prawdziwy stan</li>
265 <li>Wskaźnik "Synchronizacja..." informuje o trwającej Akcji</li>
266 </ul>
267 </div>
268 </div>
269 </div>
270 );
271}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy hook useTransition w React?
2. Co się dzieje z optymistyczną aktualizacją UI (useOptimistic), gdy żądanie do serwera zwróci błąd?
To 2 z 9 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kod komponentu, który łączy etykietę z polem przez useId:
- Układanie w pionie
Ułóż poprawną składnię użycia useTransition:
- Edytor kodu
Czat z bazą Luna wysyła wiadomości przez akcję formularza sendMessage. Łącze odpowiada po 0,6 s, a wiadomość zaczynająca się od ! udaje zakłócenia i kończy się błędem. Uzupełnij luki: ___BLANK1___ to hook Reacta 19, który tworzy stan optymistyczny z prawdziwej listy messages i funkcji dopisującej wiadomość, ___BLANK2___ to funkcja, którą pokazujesz wiadomość od razu, zanim baza odpowie, a ___BLANK3___ to lista, którą wyświetlasz w <ul>. Wysłana wiadomość ma się pojawić natychmiast z dopiskiem o wysyłaniu, po potwierdzeniu zostać bez dopisku, a przy błędzie zniknąć i zostawić komunikat.
- Układanie w pionie
Uporządkuj etapy jednego cyklu renderowania w React:
- Układanie w pionie
Uporządkuj kroki ładowania danych za pomocą hooka use() i Suspense:
- Klikanie w kolejności
Ułóż składnię hooka useCallback do stabilizacji referencji funkcji:
- Układanie w pionie
Od czego zaczynasz optymalizację wydajności? Ułóż etapy: pierwszy krok jest stały, a kolejność technik po nim zależy od tego, co pokaże pomiar.
- Układanie w poziomie
Ułóż składnię destrukturyzacji hooka useOptimistic:
- Układanie w pionie
Uporządkuj etapy pipeline optymalizacji buildu od analizy do dostarczenia:
- Edytor kodu
Analizator paczki (np. rollup-plugin-visualizer w Vite) zapisał rozmiary modułów aplikacji po kompresji. Uzupełnij raport: ___BLANK1___ to rozmiar pojedynczego modułu (pole obiektu item), który reduce dodaje do sumy, ___BLANK2___ to wyrażenie porównujące, które ustawia moduły od największego do najmniejszego, a ___BLANK3___ to warunek prawdziwy dla modułu większego niż budżet BUDGET. Raport ma pokazać łączny rozmiar 742 kB, listę od star-map (310 kB) do navigation (24 kB), a po zaznaczeniu pola tylko trzy moduły powyżej 100 kB, czyli kandydatów do wydzielenia przez lazy().
- Klikanie w kolejności
Ułóż składnię komponentu Profiler do mierzenia wydajności (atrybut id przed onRender):
- Układanie w pionie
Uporządkuj kroki diagnozowania wydajności za pomocą React Profiler:
- Edytor kodu
Galeria pokazuje zdjęcia trzech planet. Każde zdjęcie ma małą, rozmytą miniaturę widoczną od razu i pełny obraz, który pojawia się dopiero po załadowaniu. Uzupełnij komponent PlanetImage: ___BLANK1___ to wartość atrybutu loading, dzięki której przeglądarka pobierze pełny obraz dopiero, gdy zbliży się on do ekranu, ___BLANK2___ to status ustawiany po zdarzeniu load (miniatura znika, a zdjęcie staje się widoczne), a ___BLANK3___ to wywołanie, które po zdarzeniu error ustawia status 'error'. Każde zdjęcie ma własny stan, a błąd pokazuje komunikat z nazwą planety.
- Układanie w poziomie
Ułóż składnię elementu img z atrybutem lazy loading:
- Edytor kodu
Wykres TelemetryChart renderuje setki słupków, a przycisk dokłada kolejne. Zmierz jego rendery komponentem Profiler: ___BLANK1___ to trzeci parametr callbacku, czyli czas renderu w milisekundach (ta nazwa jest już użyta w ciele funkcji), ___BLANK2___ to komponent Reacta, który mierzy rendery swojego poddrzewa, a ___BLANK3___ to funkcja przekazana do jego atrybutu onRender. Po zamontowaniu w konsoli ma się pojawić wpis z fazą mount, a po kliknięciu przycisku wpis z fazą update.
- Klikanie w kolejności
Ułóż deklarację komponentu lazy-loaded z React.lazy:
- Układanie w pionie
Uporządkuj implementację lazy loading:
- Edytor kodu
Kalkulator skoku nadświetlnego liczy zużycie paliwa w wątku roboczym, żeby interfejs nie zamarzał. Wątek ma API Web Workera: postMessage wysyła dane, onmessage odbiera wynik, a terminate zatrzymuje wątek (w podglądzie funkcja createWorker go udaje). Uzupełnij luki: ___BLANK1___ to pole obiektu zdarzenia, w którym przychodzi wynik, ___BLANK2___ to metoda, którą sprzątanie efektu zatrzymuje wątek, a ___BLANK3___ to metoda, którą kliknięcie wysyła dystans do wątku. Wątek ma powstać raz po zamontowaniu, przycisk ma być zablokowany do czasu odpowiedzi, a wynik ma pojawić się na ekranie.
- Klikanie w kolejności
Uporządkuj setup Performance Observer:
- Edytor kodu
Funkcje cacheFirst, networkFirst i pickStrategy trafią do pliku sw.js i obsłużą zdarzenie fetch w Service Workerze. Pliki statyczne mają działać w strategii cache-first (najpierw kopia z cache, sieć tylko przy braku kopii), a dane z /api/ w strategii network-first (najpierw świeża odpowiedź z sieci, kopia z cache tylko bez łączności). Uzupełnij luki: ___BLANK1___ to metoda cache, która szuka zapisanej odpowiedzi, ___BLANK2___ to metoda cache, która zapisuje odpowiedź, a ___BLANK3___ to funkcja strategii, którą pickStrategy zwraca dla adresów /api/. Panel w podglądzie pozwala wyłączyć sieć i sprawdzić, skąd przychodzą odpowiedzi.
- Edytor kodu
Formularz raportu dla Centrum Kontroli korzysta z useActionState, a przycisk wysyłania jest osobnym komponentem SubmitButton, który sam ma wiedzieć, że formularz jest w trakcie wysyłki. Uzupełnij luki: ___BLANK1___ to pole zwracane przez hook, które ma wartość true w czasie wysyłki, ___BLANK2___ to hook z react-dom, który odczytuje stan formularza, w którym jest wyrenderowany komponent, ___BLANK3___ to komponent przycisku wstawiony do formularza, a ___BLANK4___ to status, który akcja zwraca dla za krótkiego raportu. Raport krótszy niż 10 znaków ma pokazać błąd, dłuższy potwierdzenie, a w czasie transmisji przycisk ma być zablokowany i zmienić napis.