Just-Use-Convex Docs
Frontend Integration

Optimistic Updates

Instant UI feedback before server confirmation

Optimistic Updates

Optimistic updates show changes immediately in the UI before the server confirms them. This makes your app feel faster and more responsive.

How It Works

1. User clicks "Complete"
2. UI immediately shows todo as done (optimistic)
3. Mutation sent to server
4. Server confirms -> UI stays as is
5. Server fails -> UI reverts to previous state

With TanStack Query

Basic Optimistic Update

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useConvexMutation } from "@convex-dev/react-query";
import { api } from "@/convex/_generated/api";

function useTodoUpdate() {
  const queryClient = useQueryClient();
  const updateFn = useConvexMutation(api.todos.update);

  return useMutation({
    mutationFn: updateFn,
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData(["todos"]);

      // Optimistically update
      queryClient.setQueryData(["todos"], (old: Todo[]) =>
        old.map((todo) =>
          todo._id === newData.id
            ? { ...todo, ...newData }
            : todo
        )
      );

      // Return context with snapshot
      return { previousTodos };
    },
    onError: (err, newData, context) => {
      // Rollback on error
      queryClient.setQueryData(["todos"], context?.previousTodos);
    },
    onSettled: () => {
      // Always refetch after mutation
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

Convex Real-Time Alternative

With Convex's real-time subscriptions, you often don't need traditional optimistic updates. The data updates automatically within ~50-200ms.

When to Use Each

ScenarioApproach
Fast networks, simple updatesRely on Convex real-time
Slow networksOptimistic updates
Drag-and-drop interactionsOptimistic updates
Critical data accuracyWait for confirmation

Simple Pending State

For most cases, showing a pending state is sufficient:

function TodoItem({ todo }) {
  const { update } = useTodoMutations();

  return (
    <div className={update.isPending ? "opacity-50" : ""}>
      <span>{todo.title}</span>
      <button
        onClick={() => update.mutate({ id: todo._id, status: "done" })}
        disabled={update.isPending}
      >
        {update.isPending ? "Saving..." : "Complete"}
      </button>
    </div>
  );
}

Local State for Instant Feedback

Toggle with Local State

function TodoCheckbox({ todo }) {
  const [localStatus, setLocalStatus] = useState(todo.status);
  const { update } = useTodoMutations();

  // Sync local state with server state
  useEffect(() => {
    setLocalStatus(todo.status);
  }, [todo.status]);

  const handleToggle = () => {
    const newStatus = localStatus === "done" ? "todo" : "done";

    // Update local state immediately
    setLocalStatus(newStatus);

    // Send to server
    update.mutate(
      { id: todo._id, status: newStatus },
      {
        onError: () => {
          // Revert on error
          setLocalStatus(todo.status);
        },
      }
    );
  };

  return (
    <input
      type="checkbox"
      checked={localStatus === "done"}
      onChange={handleToggle}
    />
  );
}

Drag and Drop

function KanbanBoard() {
  const { todos } = useTodosList();
  const { update } = useTodoMutations();
  const [localTodos, setLocalTodos] = useState(todos);

  // Sync with server data
  useEffect(() => {
    setLocalTodos(todos);
  }, [todos]);

  const handleDragEnd = (result: DropResult) => {
    if (!result.destination) return;

    const { draggableId, destination } = result;
    const newStatus = destination.droppableId as Status;

    // Optimistically update local state
    setLocalTodos((prev) =>
      prev.map((t) =>
        t._id === draggableId ? { ...t, status: newStatus } : t
      )
    );

    // Send to server
    update.mutate(
      { id: draggableId, status: newStatus },
      {
        onError: () => {
          // Revert to server state on error
          setLocalTodos(todos);
        },
      }
    );
  };

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      {/* Render localTodos instead of todos */}
    </DragDropContext>
  );
}

Optimistic Create

function TodoCreator() {
  const [pendingTodos, setPendingTodos] = useState<PendingTodo[]>([]);
  const { create } = useTodoMutations();

  const handleCreate = (title: string) => {
    const tempId = `pending-${Date.now()}`;

    // Add to pending list
    setPendingTodos((prev) => [
      ...prev,
      { tempId, title, status: "todo", isPending: true },
    ]);

    // Send to server
    create.mutate(
      { title, priority: "medium" },
      {
        onSuccess: () => {
          // Remove from pending (server data will appear via subscription)
          setPendingTodos((prev) =>
            prev.filter((t) => t.tempId !== tempId)
          );
        },
        onError: () => {
          // Mark as failed
          setPendingTodos((prev) =>
            prev.map((t) =>
              t.tempId === tempId ? { ...t, isFailed: true } : t
            )
          );
        },
      }
    );
  };

  return (
    <>
      <TodoForm onSubmit={handleCreate} />
      {pendingTodos.map((t) => (
        <PendingTodoItem key={t.tempId} todo={t} />
      ))}
    </>
  );
}

Optimistic Delete

function TodoList() {
  const { todos } = useTodosList();
  const { remove } = useTodoMutations();
  const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());

  const handleDelete = (id: string) => {
    // Mark as deleting
    setDeletingIds((prev) => new Set(prev).add(id));

    remove.mutate(
      { id },
      {
        onSettled: () => {
          // Remove from deleting set
          setDeletingIds((prev) => {
            const next = new Set(prev);
            next.delete(id);
            return next;
          });
        },
      }
    );
  };

  // Filter out deleting items for instant feedback
  const visibleTodos = todos.filter((t) => !deletingIds.has(t._id));

  return (
    <ul>
      {visibleTodos.map((todo) => (
        <TodoItem
          key={todo._id}
          todo={todo}
          onDelete={() => handleDelete(todo._id)}
        />
      ))}
    </ul>
  );
}

Best Practices

1. Handle Errors Gracefully

Always revert optimistic changes on error:

onError: () => {
  setLocalState(serverState);
  toast.error("Failed to update");
}

2. Show Pending State

Visual feedback helps users understand something is happening:

<div className={isPending ? "opacity-50 pointer-events-none" : ""}>

3. Disable Double-Submits

<button disabled={mutation.isPending}>
  {mutation.isPending ? "Saving..." : "Save"}
</button>

4. Use for UX-Critical Interactions

Focus optimistic updates on interactions where instant feedback matters most:

  • Toggles and checkboxes
  • Drag and drop
  • Like/favorite buttons
  • Real-time collaborative features

For less time-sensitive operations, rely on Convex's fast real-time updates.