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

useOptimistic - optymistyczne aktualizacje UI

18 min czytania
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  }
44

Optymistyczne 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  };
45

React 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  }
63

useRef 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  };
97

Każ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. 1. Do czego służy hook useTransition w React?

  2. 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.

Przydatne artykuły