The useOptimistic hook enables optimistic UI updates that show immediately while async operations complete. Here's how to use it.
Basic Usage#
1import { useOptimistic, useState } from 'react';
2
3function TodoList({ todos, addTodo }) {
4 const [optimisticTodos, setOptimisticTodos] = useOptimistic(
5 todos,
6 (state, newTodo) => [...state, { ...newTodo, pending: true }]
7 );
8
9 async function handleSubmit(formData) {
10 const newTodo = {
11 id: Date.now(),
12 text: formData.get('text'),
13 };
14
15 // Show immediately
16 setOptimisticTodos(newTodo);
17
18 // Actual async operation
19 await addTodo(newTodo);
20 }
21
22 return (
23 <div>
24 <form action={handleSubmit}>
25 <input name="text" />
26 <button type="submit">Add</button>
27 </form>
28
29 <ul>
30 {optimisticTodos.map((todo) => (
31 <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
32 {todo.text}
33 {todo.pending && ' (saving...)'}
34 </li>
35 ))}
36 </ul>
37 </div>
38 );
39}Like Button#
1import { useOptimistic } from 'react';
2
3function LikeButton({ postId, initialLiked, initialCount, onLike }) {
4 const [optimistic, setOptimistic] = useOptimistic(
5 { liked: initialLiked, count: initialCount },
6 (state, newLiked) => ({
7 liked: newLiked,
8 count: newLiked ? state.count + 1 : state.count - 1,
9 })
10 );
11
12 async function handleClick() {
13 const newLiked = !optimistic.liked;
14
15 // Update UI immediately
16 setOptimistic(newLiked);
17
18 // Sync with server
19 await onLike(postId, newLiked);
20 }
21
22 return (
23 <button onClick={handleClick}>
24 {optimistic.liked ? 'ā¤ļø' : 'š¤'} {optimistic.count}
25 </button>
26 );
27}Message Sending#
1import { useOptimistic, useState } from 'react';
2
3function Chat({ messages, sendMessage }) {
4 const [optimisticMessages, addOptimisticMessage] = useOptimistic(
5 messages,
6 (state, newMessage) => [
7 ...state,
8 {
9 ...newMessage,
10 sending: true,
11 },
12 ]
13 );
14
15 async function handleSend(formData) {
16 const text = formData.get('message');
17 const tempId = `temp-${Date.now()}`;
18
19 const newMessage = {
20 id: tempId,
21 text,
22 sender: 'me',
23 timestamp: new Date(),
24 };
25
26 // Show message immediately
27 addOptimisticMessage(newMessage);
28
29 // Send to server
30 await sendMessage(text);
31 }
32
33 return (
34 <div className="chat">
35 <div className="messages">
36 {optimisticMessages.map((msg) => (
37 <div
38 key={msg.id}
39 className={`message ${msg.sender} ${msg.sending ? 'sending' : ''}`}
40 >
41 {msg.text}
42 {msg.sending && <span className="status">Sending...</span>}
43 </div>
44 ))}
45 </div>
46
47 <form action={handleSend}>
48 <input name="message" placeholder="Type a message..." />
49 <button type="submit">Send</button>
50 </form>
51 </div>
52 );
53}Delete with Undo#
1import { useOptimistic, useState } from 'react';
2
3function ItemList({ items, deleteItem }) {
4 const [deletedIds, setDeletedIds] = useState(new Set());
5
6 const [optimisticItems, setOptimisticItems] = useOptimistic(
7 items.filter((item) => !deletedIds.has(item.id)),
8 (state, deletedId) => state.filter((item) => item.id !== deletedId)
9 );
10
11 async function handleDelete(id) {
12 // Show deletion immediately
13 setOptimisticItems(id);
14
15 // Start undo timer
16 const undoTimeout = setTimeout(async () => {
17 setDeletedIds((prev) => new Set([...prev, id]));
18 await deleteItem(id);
19 }, 5000);
20
21 // Return undo function
22 return () => clearTimeout(undoTimeout);
23 }
24
25 return (
26 <ul>
27 {optimisticItems.map((item) => (
28 <li key={item.id}>
29 {item.name}
30 <button onClick={() => handleDelete(item.id)}>Delete</button>
31 </li>
32 ))}
33 </ul>
34 );
35}Form Submission#
1import { useOptimistic, useActionState } from 'react';
2
3function CommentForm({ postId, comments, addComment }) {
4 const [optimisticComments, addOptimisticComment] = useOptimistic(
5 comments,
6 (state, newComment) => [
7 ...state,
8 { ...newComment, pending: true },
9 ]
10 );
11
12 async function submitComment(formData) {
13 const comment = {
14 id: `temp-${Date.now()}`,
15 text: formData.get('comment'),
16 author: 'Current User',
17 createdAt: new Date(),
18 };
19
20 addOptimisticComment(comment);
21
22 await addComment(postId, comment.text);
23 }
24
25 return (
26 <div>
27 <ul className="comments">
28 {optimisticComments.map((comment) => (
29 <li
30 key={comment.id}
31 className={comment.pending ? 'pending' : ''}
32 >
33 <strong>{comment.author}</strong>
34 <p>{comment.text}</p>
35 {comment.pending && <span>Posting...</span>}
36 </li>
37 ))}
38 </ul>
39
40 <form action={submitComment}>
41 <textarea name="comment" required />
42 <button type="submit">Post Comment</button>
43 </form>
44 </div>
45 );
46}Toggle with Multiple States#
1import { useOptimistic } from 'react';
2
3function BookmarkButton({ itemId, status, onToggle }) {
4 // status: 'none' | 'bookmarked' | 'favorited'
5 const [optimisticStatus, setOptimisticStatus] = useOptimistic(
6 status,
7 (_, newStatus) => newStatus
8 );
9
10 async function handleToggle(newStatus) {
11 setOptimisticStatus(newStatus);
12 await onToggle(itemId, newStatus);
13 }
14
15 return (
16 <div className="bookmark-actions">
17 <button
18 className={optimisticStatus === 'bookmarked' ? 'active' : ''}
19 onClick={() =>
20 handleToggle(optimisticStatus === 'bookmarked' ? 'none' : 'bookmarked')
21 }
22 >
23 š Bookmark
24 </button>
25 <button
26 className={optimisticStatus === 'favorited' ? 'active' : ''}
27 onClick={() =>
28 handleToggle(optimisticStatus === 'favorited' ? 'none' : 'favorited')
29 }
30 >
31 ā Favorite
32 </button>
33 </div>
34 );
35}Cart Updates#
1import { useOptimistic } from 'react';
2
3function CartItem({ item, updateQuantity, removeItem }) {
4 const [optimisticItem, setOptimisticItem] = useOptimistic(
5 item,
6 (state, update) => ({
7 ...state,
8 ...update,
9 updating: true,
10 })
11 );
12
13 async function handleQuantityChange(newQuantity) {
14 if (newQuantity === 0) {
15 setOptimisticItem({ removed: true });
16 await removeItem(item.id);
17 } else {
18 setOptimisticItem({ quantity: newQuantity });
19 await updateQuantity(item.id, newQuantity);
20 }
21 }
22
23 if (optimisticItem.removed) {
24 return null;
25 }
26
27 return (
28 <div className={`cart-item ${optimisticItem.updating ? 'updating' : ''}`}>
29 <span>{item.name}</span>
30 <div className="quantity-controls">
31 <button onClick={() => handleQuantityChange(optimisticItem.quantity - 1)}>
32 -
33 </button>
34 <span>{optimisticItem.quantity}</span>
35 <button onClick={() => handleQuantityChange(optimisticItem.quantity + 1)}>
36 +
37 </button>
38 </div>
39 <span>${(item.price * optimisticItem.quantity).toFixed(2)}</span>
40 </div>
41 );
42}Error Handling#
1import { useOptimistic, useState } from 'react';
2
3function EditableItem({ item, updateItem }) {
4 const [error, setError] = useState(null);
5
6 const [optimisticItem, setOptimisticItem] = useOptimistic(
7 item,
8 (state, newValue) => ({ ...state, value: newValue, saving: true })
9 );
10
11 async function handleSave(newValue) {
12 setError(null);
13 setOptimisticItem(newValue);
14
15 try {
16 await updateItem(item.id, newValue);
17 } catch (err) {
18 setError(err.message);
19 // The optimistic update will be reverted when
20 // the actual item prop updates
21 }
22 }
23
24 return (
25 <div>
26 <input
27 value={optimisticItem.value}
28 onChange={(e) => handleSave(e.target.value)}
29 disabled={optimisticItem.saving}
30 />
31 {optimisticItem.saving && <span>Saving...</span>}
32 {error && <span className="error">{error}</span>}
33 </div>
34 );
35}Batch Operations#
1import { useOptimistic } from 'react';
2
3function BulkActions({ items, selectedIds, onBulkDelete, onBulkArchive }) {
4 const [optimisticItems, setOptimisticItems] = useOptimistic(
5 items,
6 (state, action) => {
7 switch (action.type) {
8 case 'delete':
9 return state.filter((item) => !action.ids.includes(item.id));
10 case 'archive':
11 return state.map((item) =>
12 action.ids.includes(item.id)
13 ? { ...item, archived: true, pending: true }
14 : item
15 );
16 default:
17 return state;
18 }
19 }
20 );
21
22 async function handleBulkDelete() {
23 setOptimisticItems({ type: 'delete', ids: [...selectedIds] });
24 await onBulkDelete([...selectedIds]);
25 }
26
27 async function handleBulkArchive() {
28 setOptimisticItems({ type: 'archive', ids: [...selectedIds] });
29 await onBulkArchive([...selectedIds]);
30 }
31
32 return (
33 <div>
34 <div className="actions">
35 <button onClick={handleBulkDelete} disabled={selectedIds.size === 0}>
36 Delete Selected
37 </button>
38 <button onClick={handleBulkArchive} disabled={selectedIds.size === 0}>
39 Archive Selected
40 </button>
41 </div>
42
43 <ul>
44 {optimisticItems.map((item) => (
45 <li key={item.id} className={item.pending ? 'pending' : ''}>
46 {item.name}
47 </li>
48 ))}
49 </ul>
50 </div>
51 );
52}Best Practices#
When to Use:
ā Likes/favorites/bookmarks
ā Adding items to lists
ā Form submissions
ā Toggle states
ā Real-time updates
Patterns:
ā Show pending state visually
ā Handle errors gracefully
ā Revert on failure
ā Use with server actions
Visual Feedback:
ā Reduce opacity for pending
ā Show loading indicators
ā Disable during update
ā Clear error states
Avoid:
ā Critical data without confirmation
ā Complex multi-step operations
ā Operations that can't be reverted
ā Ignoring error states
Conclusion#
The useOptimistic hook provides immediate UI feedback while async operations complete in the background. Use it for actions like likes, adding items, and form submissions where instant feedback improves perceived performance. Always provide visual indicators for pending states and handle errors gracefully by reverting the UI when operations fail. Combine with server actions for a seamless optimistic update pattern.