JavaScript and React course Β· Module 9: Modern React Hooks

useOptimistic - optimistic UI updates

19 min read
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  }
44

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

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

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

Every 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. 1. What is the useTransition hook used for in React?

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

Useful articles