JavaScript and React course Β· Module 9: Modern React Hooks
useOptimistic - optimistic UI updates
In this lesson4
A message to the Mars base takes several minutes to arrive, but the crew does not want to stare at a spinner. They want to see their message on the list right away, marked "sending". If the transmission fails, the message should disappear. That is an optimistic update: we assume success, and on failure we go back to the real state.
The useOptimistic hook allows you to instantly update the user interface before receiving a response from the server. This is a key tool for creating responsive applications that give the user the impression of instantaneous reaction.
useOptimistic Basics
1. Syntax and basic usage
The hook takes the real state and an update function, and returns the optimistic state plus a function that changes it. The most important rule from react.dev: that function must be called inside an Action, meaning in startTransition or in a form action. Outside an Action, React prints a warning and the change flashes and disappears at once. First, the task list logic:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function TodoList() {
4 const [todos, setTodos] = useState([
5 { id: 1, text: 'Learn React', completed: false },
6 { id: 2, text: 'Build an app', completed: false }
7 ]);
8
9 const [optimisticTodos, addOptimisticTodo] = useOptimistic(
10 todos,
11 // Function updating optimistic state
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 // Mark as pending
21 };
22
23 // Optimistic update
24 addOptimisticTodo(newTodo);
25
26 try {
27 // Actual API call
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 // Update actual state
36 startTransition(() => {
37 setTodos(current => [...current, savedTodo]);
38 });
39 } catch (error) {
40 // On error, optimistic state will be automatically rolled back
41 console.error('Error adding todo:', error);
42 }
43 }
44The optimistic task exists only while the Action is running. When it ends, React shows the plain todos state. If saving succeeded, setTodos has already added the real task, and if it failed, the state is what it was before, so the change "rolls back" on its own. We wrap the update after await in another startTransition, as the examples in the docs do. Here is the view:
1 return (
2 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
3 <h2>Task List</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 Add
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 (saving...)
73 </span>
74 )}
75 </li>
76 ))}
77 </ul>
78 </div>
79 );
80}The form calls handleAddTodo inside startTransition, so the optimistic entry marked "(saving...)" stays on screen until the server responds.
2. Optimistic toggle with error handling
A like button is the classic case: the reaction must be instant, and the server confirms it in the background. Here the update function simply returns the new value:
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 // Optimistic update
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('Update error');
29 }
30
31 const data = await response.json();
32
33 // Update actual state
34 setLikes(data.likes);
35 setIsLiked(data.isLiked);
36 } catch (error) {
37 // Optimistic state will be automatically rolled back
38 alert('Failed to update like');
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 ? 'Liked' : 'Like'}</span>
60 <span>{optimisticLikes.count}</span>
61 </button>
62 );
63}
64
65// Example usage in a post component
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('en-US')}
92 </span>
93 </div>
94 </article>
95 );
96}A click runs handleLikeToggle inside startTransition. When the server returns an error, the catch block shows a message, and the counter goes back to the value from likes, because the real state did not change.
3. Optimistic sorting and filtering
The update function works like a reducer: it receives the current state and a description of the change. That way one call handles both the checkbox and the priority change:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticTaskList() {
4 const [tasks, setTasks] = useState([
5 { id: 1, title: 'Task 1', priority: 'low', completed: false },
6 { id: 2, title: 'Task 2', priority: 'high', completed: false },
7 { id: 3, title: 'Task 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 // Optimistic update
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 // Update actual state
32 setTasks(current =>
33 current.map(task => task.id === taskId ? updatedTask : task)
34 );
35 } catch (error) {
36 console.error('Error updating task:', error);
37 }
38 }
39
40 const priorityColors = {
41 low: '#28a745',
42 medium: '#ffc107',
43 high: '#dc3545'
44 };
45React runs this function again if the real tasks changes during the Action, so the change is always applied on top of current data. Here is the view with statistics:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Task Management</h2>
4
5 <div style={{ marginBottom: '20px' }}>
6 <h3>Statistics:</h3>
7 <div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
8 <span>
9 Completed: {optimisticTasks.filter(t => t.completed).length}
10 </span>
11 <span>
12 In progress: {optimisticTasks.filter(t => !t.completed).length}
13 </span>
14 <span>
15 High priority: {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">Low</option>
70 <option value="medium">Medium</option>
71 <option value="high">High</option>
72 </select>
73
74 {task.updating && (
75 <span style={{
76 marginLeft: '10px',
77 color: '#007bff',
78 fontSize: '12px'
79 }}>
80 Saving...
81 </span>
82 )}
83 </div>
84 </div>
85 ))}
86 </div>
87 </div>
88 );
89}We compute the statistics from optimisticTasks, so the counters change together with the checkbox, without waiting for the server.
Advanced patterns with useOptimistic
1. Optimistic deletion with animation
This time the optimistic state does not remove the item, it marks it with a deleting flag, which leaves time for an animation:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function OptimisticDeleteList() {
4 const [items, setItems] = useState([
5 { id: 1, name: 'Item 1', createdAt: new Date() },
6 { id: 2, name: 'Item 2', createdAt: new Date() },
7 { id: 3, name: 'Item 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 // Optimistically mark as deleting
20 removeOptimisticItem(itemId);
21
22 // Animation before deletion
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('Deletion error');
32 }
33
34 // Actually remove from state
35 setItems(current => current.filter(item => item.id !== itemId));
36 } catch (error) {
37 // Rollback optimistic change
38 alert('Failed to delete item');
39 }
40 }
41
42 return (
43 <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
44 <h2>List with optimistic deletion</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 Created: {item.createdAt.toLocaleString('en-US')}
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 ? 'Deleting...' : 'Delete'}
86 </button>
87 </div>
88 ))}
89 </div>
90 </div>
91 );
92}The animation delay is now a plain await inside the Action. If the request sat inside setTimeout, the Action would end immediately and the deleting flag would vanish before the deletion.
2. Optimistic real-time collaboration
The shared editor combines two optimistic states with a WebSocket connection. Treat it as an architecture sketch:
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 or other real-time connection
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 // Optimistic update
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('Error saving:', error);
55 }
56 }
57
58 function handleCursorMove(position) {
59 updateOptimisticCollaborators({
60 type: 'cursor',
61 userId,
62 position
63 });
64
65 // Send cursor update via 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>Shared Editor</h2>
72
73 <div style={{ marginBottom: '10px' }}>
74 <h4>Active users:</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 (line {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 Last sync: {new Date().toLocaleTimeString('en-US')}
125 </div>
126 </div>
127 );
128}The cursor reacts to the onSelect event, because React has no onSelectionChange event for a text field. Two warnings: react.dev advises against controlling a text field through transitions, so text typed by the user is better kept in plain state. The cursor position, in turn, does not wait for the server, so a plain useState fits it better than optimistic state.
3. Batch updates with queuing
A chat can send messages in batches of five. First the queue and its processing:
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 at a time
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('Error sending batch:', error);
34 // Return to queue
35 updateQueue.current.unshift(...batch);
36 } finally {
37 isProcessing.current = false;
38 // Process next batch if there are items
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 // Optimistic update
55 addOptimisticMessage(newMessage);
56
57 // Add to queue
58 updateQueue.current.push(newMessage);
59
60 // Start processing if not active
61 await processBatch();
62 }
63useRef keeps the queue outside of state, so changing it does not trigger a render. Now sending and the view:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h2>Chat with 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('en-US')}</span>
34 <span>
35 {message.pending ? 'Sending...' : 'Sent'}
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 Send
74 </button>
75 </form>
76
77 <div style={{
78 marginTop: '10px',
79 fontSize: '12px',
80 color: '#6c757d'
81 }}>
82 In queue: {updateQueue.current.length} |
83 Processing: {isProcessing.current ? 'Yes' : 'No'}
84 </div>
85 </div>
86 );
87}sendMessage waits for processBatch, so the message stays optimistic until it is sent. Note: when a batch is already on its way, processBatch returns immediately, and the next message may disappear before it is sent. The queue counter also reads a ref, so it does not refresh by itself.
Practical applications of useOptimistic
1. Comment system with reactions
One reducer handles three kinds of changes: adding a comment, a reaction and a reply. First the logic:
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: 'You',
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('Error adding comment:', 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 // Update actual state
82 const response = await fetch(`/api/posts/${postId}/comments`);
83 const updatedComments = await response.json();
84 setComments(updatedComments);
85 } catch (error) {
86 console.error('Error reacting:', error);
87 }
88 }
89
90 const reactionLabels = {
91 like: 'Like',
92 love: 'Love',
93 laugh: 'Haha',
94 wow: 'Wow',
95 sad: 'Sad'
96 };
97Every change goes through updateOptimisticComment with a type field, just like actions in useReducer. Now the view:
1 return (
2 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
3 <h3>Comments</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 Add comment
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('en-US')}
62 </span>
63 {comment.pending && (
64 <span style={{
65 marginLeft: '10px',
66 color: '#007bff',
67 fontSize: '12px'
68 }}>
69 (sending...)
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}The reaction buttons and the form run their functions inside startTransition, so the counters grow instantly.
2. Drag & Drop with optimistic sorting
Dragging items is another case where the user cannot wait for the server:
1import { useOptimistic, useState, startTransition } from 'react';
2
3function DragDropList() {
4 const [items, setItems] = useState([
5 { id: 1, text: 'Item 1', order: 0 },
6 { id: 2, text: 'Item 2', order: 1 },
7 { id: 3, text: 'Item 3', order: 2 },
8 { id: 4, text: 'Item 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 // Optimistic update
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('Error reordering:', error);
40 }
41 }
42
43 return (
44 <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
45 <h2>Drag & Drop List</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 Drag items to change the order
100 </p>
101 </div>
102 );
103}The new order appears right after the drop, and the server only confirms it.
Best practices with useOptimistic
1. Data structure
Mark optimistic data so the interface can highlight it:
1// GOOD - marking optimistic state
2const optimisticItem = {
3 ...originalItem,
4 optimistic: true, // or pending: true
5 optimisticId: Date.now() // for identification
6};
7
8// GOOD - preserving original data
9const [optimistic, update] = useOptimistic(
10 realData,
11 (current, change) => ({
12 ...current,
13 ...change,
14 _original: current // keep the original
15 })
16);
17
18// BAD - no state marking
19const badOptimistic = {
20 ...originalItem
21 // No way to distinguish from actual data
22};A pending or optimistic flag lets you show "sending" and block actions on unconfirmed items.
2. Error handling
Retrying with exponential backoff fits temporary network failures:
1// Retry strategy with 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 // Show error to user
22 showErrorNotification('Operation failed. Please try again.');
23 throw lastError;
24}Remember that the optimistic state lasts through all the retries, because the whole function runs in a single Action.
3. Performance
Finally, two performance patterns:
1// GOOD - batch updates
2const [optimistic, batchUpdate] = useOptimistic(
3 items,
4 (current, updates) => {
5 // Process multiple updates at once
6 return updates.reduce((acc, update) => {
7 // Apply each update
8 return applyUpdate(acc, update);
9 }, current);
10 }
11);
12
13// Usage
14function handleMultipleUpdates(updates) {
15 batchUpdate(updates); // One update instead of many
16}
17
18// GOOD - memoization of heavy computations
19const processedOptimisticData = useMemo(() => {
20 return expensiveProcessing(optimisticData);
21}, [optimisticData]);A single call with an array of changes gives one reducer pass instead of many.
The useOptimistic hook is the key to creating responsive applications that give the user instant feedback. Proper use of this hook can significantly improve the perceived performance of an application.
My advice: the simplest way is to use useOptimistic with a form action, because React wraps it in a transition for you. In this module's project you will combine the hook with useActionState. Remember: optimism on the bridge lasts only until the report from Mars arrives, then the real state is what counts.
Code for this lesson: App.jsx
1import { useState, useOptimistic, useTransition } from 'react';
2
3// API simulation: delayed server responses
4function simulateAPI(action, data) {
5 return new Promise((resolve, reject) => {
6 setTimeout(() => {
7 // 10% chance of an error
8 if (Math.random() < 0.1) {
9 reject(new Error(`Server error during: ${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 Syncing...
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 Delete
78 </button>
79 </div>
80 );
81}
82
83export default function App() {
84 const [missions, setMissions] = useState([
85 { id: 1, name: 'Explore the orbit of Mars', completed: false, priority: 'High' },
86 { id: 2, name: 'Repair the communications module', completed: true, priority: 'Critical' },
87 { id: 3, name: 'Collect samples from Io', completed: false, priority: 'Medium' },
88 { id: 4, name: 'Calibrate the telescope', completed: false, priority: 'Low' },
89 ]);
90
91 const [newMission, setNewMission] = useState('');
92 const [error, setError] = useState(null);
93 const [isPending, startTransition] = useTransition();
94
95 // useOptimistic - instant UI updates
96 const [optimisticMissions, addOptimistic] = useOptimistic(
97 missions,
98 // Reducer: (current state, optimistic change) => new optimistic state
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 // Every change is an Action in startTransition: the optimistic state lasts until the async function finishes
120 function handleToggle(id) {
121 setError(null);
122 startTransition(async () => {
123 addOptimistic({ type: 'toggle', id });
124 try {
125 await simulateAPI('toggle', { id });
126 // We wrap the update after await in another startTransition, as react.dev advises
127 startTransition(() => {
128 setMissions(prev =>
129 prev.map(m => m.id === id ? { ...m, completed: !m.completed } : m)
130 );
131 });
132 } catch (err) {
133 // Error: we change nothing, the optimistic state disappears by itself when the Action ends
134 setError(`Could not change the mission status (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: 'Medium' };
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('Could not add the mission');
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(`Could not delete the mission (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 - Space missions
186 </h1>
187 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px', fontSize: '13px' }}>
188 Changes visible immediately, synchronization in the background
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>Mission progress {isPending && <em style={{ color: '#00d4ff' }}>(syncing)</em>}</span>
201 <span>{completed}/{total} completed</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="New mission..."
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 + Add
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' }}>How useOptimistic works:</strong>
261 <ul style={{ margin: '8px 0', paddingLeft: '18px' }}>
262 <li>The UI updates immediately (optimistically)</li>
263 <li>Synchronization with the server runs in the background (1-2.5 s)</li>
264 <li>On an error (10% chance) the optimistic state disappears and the real state comes back</li>
265 <li>The "Syncing..." indicator shows that an Action is running</li>
266 </ul>
267 </div>
268 </div>
269 </div>
270 );
271}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the useTransition hook used for in React?
2. What happens with an optimistic UI update (useOptimistic) when the server request returns an error?
These are 2 of 9 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the code of a component that connects a label with a field using useId:
- Vertical ordering
Arrange the correct syntax for using useTransition:
- Code editor
The chat with the Luna base sends messages through the form action sendMessage. The link answers after 0.6 s, and a message that starts with ! pretends to hit interference and ends with an error. Fill in the blanks: ___BLANK1___ is the React 19 hook that creates an optimistic state from the real messages list and a function that appends a message, ___BLANK2___ is the function you use to show the message right away, before the base answers, and ___BLANK3___ is the list you display in the <ul>. A sent message should appear immediately with a sending note, stay without the note after the confirmation, and on an error disappear and leave a message.
- Vertical ordering
Arrange the stages of one React render cycle in order:
- Vertical ordering
Arrange the steps of loading data with the use() hook and Suspense:
- Click in order
Arrange the syntax of the useCallback hook for stabilizing function references:
- Vertical ordering
Where does performance optimization start? Arrange the steps: the first step is fixed, the order of the techniques after it depends on what the measurement shows.
- Horizontal ordering
Arrange the destructuring syntax of the useOptimistic hook:
- Vertical ordering
Arrange the stages of the build optimization pipeline from analysis to delivery:
- Code editor
A bundle analyzer (e.g. rollup-plugin-visualizer in Vite) saved the sizes of the app modules after compression. Complete the report: ___BLANK1___ is the size of a single module (a field of the item object) that reduce adds to the sum, ___BLANK2___ is the compare expression that orders the modules from the biggest to the smallest, and ___BLANK3___ is the condition that is true for a module bigger than the BUDGET. The report should show a total size of 742 kB, a list from star-map (310 kB) to navigation (24 kB), and with the checkbox ticked only the three modules above 100 kB, the candidates for splitting out with lazy().
- Click in order
Arrange the syntax of the React Profiler component for measuring performance (the id attribute before onRender):
- Vertical ordering
Arrange the steps for diagnosing performance with React Profiler:
- Code editor
The gallery shows pictures of three planets. Every picture has a small, blurred thumbnail visible right away and a full image that appears only after it has loaded. Complete the PlanetImage component: ___BLANK1___ is the value of the loading attribute that makes the browser download the full image only when it gets close to the screen, ___BLANK2___ is the status set after the load event (the thumbnail disappears and the picture becomes visible), and ___BLANK3___ is the call that sets the status 'error' after the error event. Every picture has its own state, and an error shows a message with the planet name.
- Horizontal ordering
Arrange the syntax of an img element with the lazy loading attribute:
- Code editor
The TelemetryChart renders hundreds of bars, and the button adds more. Measure its renders with the Profiler component: ___BLANK1___ is the third parameter of the callback, the render time in milliseconds (the name is already used in the function body), ___BLANK2___ is the React component that measures the renders of its subtree, and ___BLANK3___ is the function passed to its onRender attribute. After mounting, an entry with the mount phase should appear in the console, and after clicking the button an entry with the update phase.
- Click in order
Arrange the declaration of a lazy-loaded component with React.lazy:
- Vertical ordering
Arrange the lazy loading implementation in order:
- Code editor
The hyperspace jump calculator computes the fuel use in a worker thread, so that the interface does not freeze. The thread has the Web Worker API: postMessage sends data, onmessage receives the result and terminate stops the thread (in the preview the createWorker function imitates it). Fill in the blanks: ___BLANK1___ is the field of the event object that carries the result, ___BLANK2___ is the method the effect cleanup uses to stop the thread, and ___BLANK3___ is the method the click uses to send the distance to the thread. The thread should be created once after mounting, the button should stay disabled until the answer comes, and the result should appear on the screen.
- Click in order
Arrange the Performance Observer setup in order:
- Code editor
The cacheFirst, networkFirst and pickStrategy functions go into the sw.js file and handle the fetch event in the Service Worker. Static files should use the cache-first strategy (a copy from the cache first, the network only when there is no copy), and data from /api/ the network-first strategy (a fresh response from the network first, a copy from the cache only when offline). Fill in the blanks: ___BLANK1___ is the cache method that looks for a saved response, ___BLANK2___ is the cache method that saves a response, and ___BLANK3___ is the strategy function that pickStrategy returns for /api/ addresses. The panel in the preview lets you switch the network off and check where the responses come from.
- Code editor
The report form for Mission Control uses useActionState, and the submit button is a separate SubmitButton component that should know by itself that the form is being sent. Fill in the blanks: ___BLANK1___ is the field returned by the hook that is true while sending, ___BLANK2___ is the react-dom hook that reads the state of the form the component is rendered in, ___BLANK3___ is the button component placed in the form, and ___BLANK4___ is the status the action returns for a report that is too short. A report shorter than 10 characters should show an error, a longer one a confirmation, and during the transmission the button should be disabled and change its label.