DEV Community

Atlas Whoff
Atlas Whoff

Posted on

React 19 useOptimistic: instant UI updates without complexity

useOptimistic is the React 19 hook that makes UI updates feel instant. You don't wait for the server to confirm — you show the result immediately and reconcile after.

Here's how to use it correctly, and why the naive implementation breaks.

The pattern

'use client';
import { useOptimistic, useTransition } from 'react';

interface Message {
  id: string;
  content: string;
  status: 'pending' | 'sent' | 'failed';
}

export function MessageThread({ messages }: { messages: Message[] }) {
  const [pending, startTransition] = useTransition();

  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: Message) => [...state, newMessage]
  );

  async function sendMessage(content: string) {
    const optimisticId = crypto.randomUUID();

    // Show the message immediately with 'pending' status
    addOptimisticMessage({
      id: optimisticId,
      content,
      status: 'pending',
    });

    startTransition(async () => {
      try {
        // Actual server action
        await createMessage(content);
        // On success: React discards the optimistic state,
        // replaces it with the real server data from the next render
      } catch (error) {
        // On failure: the optimistic state is automatically rolled back
        // Show the user an error somehow
      }
    });
  }

  return (
    <div>
      {optimisticMessages.map((message) => (
        <div key={message.id} className={message.status === 'pending' ? 'opacity-60' : ''}>
          {message.content}
          {message.status === 'pending' && <span>Sending...</span>}
        </div>
      ))}
      <MessageInput onSend={sendMessage} disabled={pending} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. User sends a message
  2. addOptimisticMessage immediately adds it to the displayed list with status: 'pending'
  3. The server action runs asynchronously
  4. On success: React re-renders with real server data, replacing the optimistic entry
  5. On failure: The optimistic entry is removed (rolled back to pre-optimistic state)

The user sees instant feedback. The server stays authoritative.

The naive version that breaks

The most common mistake is doing optimistic updates in useState instead:

// WRONG — manual optimistic update
const [messages, setMessages] = useState(initialMessages);

async function sendMessage(content: string) {
  // Add optimistically
  const temp = { id: 'temp', content, status: 'pending' };
  setMessages(prev => [...prev, temp]);

  try {
    const real = await createMessage(content);
    // Remove temp, add real — race condition if multiple messages sent
    setMessages(prev => prev.filter(m => m.id !== 'temp').concat(real));
  } catch {
    setMessages(prev => prev.filter(m => m.id !== 'temp'));
  }
}
Enter fullscreen mode Exit fullscreen mode

Problems with this approach:

  1. Race conditions — send message A and B quickly, temp IDs collide
  2. State divergence — if the server sends a real-time update (WebSocket) while the optimistic state is active, you have two sources of truth
  3. No automatic rollback — you have to manually handle every failure case
  4. Not concurrent mode safe — React 18+ can interrupt renders; manual state management breaks

useOptimistic handles all of these correctly.

Optimistic updates for lists (add, update, delete)

Adding items:

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
  todos,
  (state, newTodo: Todo) => [...state, newTodo]
);
Enter fullscreen mode Exit fullscreen mode

Updating items:

const [optimisticTodos, updateOptimisticTodo] = useOptimistic(
  todos,
  (state, { id, changes }: { id: string; changes: Partial<Todo> }) =>
    state.map(todo => todo.id === id ? { ...todo, ...changes } : todo)
);

// Usage: mark as complete immediately
updateOptimisticTodo({ id: todoId, changes: { completed: true } });
await toggleTodoComplete(todoId); // Server action
Enter fullscreen mode Exit fullscreen mode

Deleting items:

const [optimisticTodos, removeOptimisticTodo] = useOptimistic(
  todos,
  (state, idToRemove: string) => state.filter(todo => todo.id !== idToRemove)
);

// Usage: remove immediately
removeOptimisticTodo(todoId);
await deleteTodo(todoId); // Server action
Enter fullscreen mode Exit fullscreen mode

With Server Actions

useOptimistic is designed to work with Next.js Server Actions:

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const content = formData.get('content') as string;

  await db.post.create({
    data: { content, authorId: getCurrentUserId() },
  });

  revalidatePath('/feed'); // Triggers re-render with real data
}
Enter fullscreen mode Exit fullscreen mode
// components/Feed.tsx
'use client'
import { useOptimistic } from 'react';
import { createPost } from '@/app/actions';

export function Feed({ posts }: { posts: Post[] }) {
  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    posts,
    (state, newPost: Post) => [newPost, ...state]
  );

  async function handleSubmit(formData: FormData) {
    addOptimisticPost({
      id: 'optimistic-' + Date.now(),
      content: formData.get('content') as string,
      author: { name: 'You' },
      createdAt: new Date(),
    });

    await createPost(formData);
    // revalidatePath in the server action triggers a re-render
    // useOptimistic replaces the optimistic entry with the real data
  }

  return (
    <>
      <PostForm action={handleSubmit} />
      {optimisticPosts.map(post => <PostCard key={post.id} post={post} />)}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Showing status: pending vs confirmed

The pattern that works best for collaborative UIs:

const [optimisticItems, dispatchOptimistic] = useOptimistic(
  serverItems,
  (state, action: { type: 'add' | 'update' | 'delete'; payload: any }) => {
    switch (action.type) {
      case 'add':
        return [...state, { ...action.payload, _optimistic: true }];
      case 'update':
        return state.map(item =>
          item.id === action.payload.id
            ? { ...item, ...action.payload, _optimistic: true }
            : item
        );
      case 'delete':
        return state.filter(item => item.id !== action.payload.id);
    }
  }
);

// In the render
{optimisticItems.map(item => (
  <Item
    key={item.id}
    item={item}
    pending={item._optimistic === true} // Show subtle loading state
  />
))}
Enter fullscreen mode Exit fullscreen mode

The _optimistic flag lets you show a subtle pending state (reduced opacity, spinner) on items that haven't been confirmed by the server yet.

When NOT to use useOptimistic

  • Payment flows — never optimistically show a payment as succeeded. The server must confirm.
  • Irreversible operations — deleting important data should wait for confirmation. Optimistically "deleting" and then the server fails means a confusing rollback.
  • Real-time collaborative editing — optimistic updates and operational transforms don't mix well without careful design.

The rule of thumb: if being wrong for 200ms would confuse or harm the user, wait for server confirmation.


useOptimistic is one of the cleanest APIs in React 19. It solves a real problem (optimistic UI state) with minimal boilerplate and correct concurrent mode behavior. The useTransition + useOptimistic combination handles 90% of the "instant feedback" patterns you need in a modern web app.

The AI SaaS Starter Kit includes patterns for optimistic updates throughout the dashboard — form submissions, list operations, and status changes. It's wired for React 19 from the start.

Top comments (0)