Optimistic updates are a lie the UI tells the user to make it feel faster than it actually is.

Let’s say you’re building a to-do list app with Next.js. When a user marks a task as complete, you want that checkmark to appear instantly, even though the actual API call to update the server might take a few hundred milliseconds.

Here’s a simplified component:

'use client';

import { useState } from 'react';

async function updateTodo(id: number, completed: boolean) {
  // Simulate network latency
  await new Promise(resolve => setTimeout(resolve, 500));
  // In a real app, this would be a fetch call to your API
  console.log(`API: Updating todo ${id} to completed: ${completed}`);
  // Simulate potential server error
  if (Math.random() < 0.1) {
    throw new Error('Server error: Failed to update todo');
  }
  return { success: true };
}

export default function TodoItem({ todo }: { todo: { id: number; text: string; completed: boolean } }) {
  const [isCompleting, setIsCompleting] = useState(false);
  const [isCompleted, setIsCompleted] = useState(todo.completed);

  const handleComplete = async () => {
    setIsCompleting(true);
    setIsCompleted(true); // Optimistic update!

    try {
      await updateTodo(todo.id, true);
      console.log('API call succeeded.');
    } catch (error) {
      console.error('API call failed:', error);
      // Revert the optimistic update on error
      setIsCompleted(todo.completed);
      alert('Failed to mark todo as complete. Please try again.');
    } finally {
      setIsCompleting(false);
    }
  };

  return (

    <div style={{ display: 'flex', alignItems: 'center', gap: '10px', margin: '5px' }}>

      <input
        type="checkbox"
        checked={isCompleted}
        onChange={handleComplete}
        disabled={isCompleting}
      />

      <span style={{ textDecoration: isCompleted ? 'line-through' : 'none' }}>

        {todo.text}
      </span>
    </div>
  );
}

When handleComplete is called, setIsCompleted(true) fires immediately. The checkbox visually updates, and the text gets a line-through. Only then does the updateTodo function start its simulated network request. If updateTodo succeeds, great. If it fails, we catch the error and setIsCompleted(todo.completed) reverts the UI back to its original state, showing the user the real outcome.

This pattern is about managing the perceived latency of asynchronous operations. The core problem it solves is the jarring experience of a user interacting with an element, and then having to wait for a visual confirmation that might not even arrive due to network issues or server hiccups. By updating the UI speculatively, you create a feeling of immediate responsiveness. The system anticipates success.

The mental model here is a race between the UI update and the network request. The UI wins the race by assuming it will win. If the network request eventually reports failure, the UI has to backtrack. The critical piece to manage is this rollback mechanism. Without it, users are left with a UI state that doesn’t reflect reality, leading to confusion and distrust.

The isCompleting state is crucial here. It’s used to disable the checkbox while the operation is in flight. This prevents a user from clicking the same item multiple times in rapid succession, which could lead to a cascade of failed or conflicting optimistic updates. Imagine clicking "complete" twice very quickly: the first click optimistically marks it complete, the second click might try to optimistically un-complete it, all while the first update is still pending. Disabling the input prevents this chaos.

When you’re building these, always think about the error state first. How does the UI snap back to reality gracefully? What’s the user experience when the optimistic assumption is proven wrong? The most robust systems have a clear, immediate, and non-disruptive way to signal failure and revert the UI.

The next challenge after mastering optimistic updates is handling complex, multi-step mutations where the rollback logic becomes significantly more intricate.

Want structured learning?

Take the full Nextjs course →