Smooth the Drop with Optimistic Updates

Our drag-and-drop feature works. If you drop a task in a different column, its status changes in Convex and the board updates.

But the interaction still feels a bit wrong.

When you release the task over a valid column, the floating overlay disappears, the task seems to snap back to the source column, and then a moment later it appears in the destination column.

That visual glitch happens because the board is still rendered from this query:

const tasks = useQuery(api.tasks.list);

The mutation runs immediately, but the query result does not change immediately. Convex still has to:

  1. receive the mutation
  2. update the database
  3. re-run tasks.list
  4. send the updated query result back to the browser

During that short gap, React still sees the old tasks array, so the task is still rendered in the source column.

Why dnd-kit Is not Enough by Itself

You might expect that dropping on a valid useDroppable target would move the card into that column on its own.

But dnd-kit does not manage your data. It only tells you:

  • which item was dragged
  • which droppable it was released over

Your app still decides what the UI should render after the drop.

Right now, our UI is driven by Convex query data. So until that query changes, the DOM still shows the task in the old column.

That also explains why the cleanup from the previous section helps, but does not fully solve the problem. Hiding the original card and letting DragOverlay handle the moving preview makes the drag look better, but after the drop the board still renders from the old query result.

Optimistic Updates

An optimistic update lets us change the local query result right away, before the server responds. The change is temporary.

That means the timeline becomes:

drop task
   ↓
call mutation
   ↓
update local query result immediately
   ↓
React re-renders and shows the task in the destination column
   ↓
server confirms the mutation and sends the real query result

So the user sees the task land where they dropped it, instead of snapping back first.

Create a Shared Mutation Hook

Create src/hooks/use-update-task-status.ts:

import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";

function useUpdateTaskStatus() {
  return useMutation(api.tasks.updateStatus).withOptimisticUpdate(
    (localStore, args) => {
      const tasks = localStore.getQuery(api.tasks.list, {});

      if (!tasks) {
        return;
      }

      localStore.setQuery(
        api.tasks.list,
        {},
        tasks.map((task) =>
          task._id === args.id ? { ...task, status: args.status } : task,
        ),
      );
    },
  );
}

export default useUpdateTaskStatus;

Let’s break that down:

  • withOptimisticUpdate(...) — tells Convex to apply a temporary local update whenever this mutation is called.
  • localStore.getQuery(api.tasks.list, {}) — reads the current cached result of the tasks.list query from the Convex client.
  • localStore.setQuery(...) — writes a temporary replacement query result into the client.
  • tasks.map(...) — creates a new array where only the dragged task gets a new status.

Update KanbanBoard

In src/components/kanban-board.tsx, switch from the direct mutation to the shared hook:

- import { useQuery, useMutation } from "convex/react";
+ import { useQuery } from "convex/react";
+ import useUpdateTaskStatus from "@/hooks/use-update-task-status";

Then inside the component:

- const updateStatus = useMutation(api.tasks.updateStatus);
+ const updateStatus = useUpdateTaskStatus();

The rest of handleDragEnd can stay the same. The difference is that updateStatus(...) now updates the local query result immediately.

Update TaskCard

In src/components/task-card.tsx, do the same replacement. Add the import:

  import { useMutation } from "convex/react";
+ import useUpdateTaskStatus from "@/hooks/use-update-task-status";
  import { api } from "../../convex/_generated/api";

Then inside the component:

- const updateStatus = useMutation(api.tasks.updateStatus);
+ const updateStatus = useUpdateTaskStatus();
  const removeTask = useMutation(api.tasks.remove);

Now both the drag-and-drop flow and the dropdown menu actions use the same optimistic status update logic.

What Convex Does Next

The optimistic update is only temporary.

After the mutation finishes:

  • Convex sends back the real query result
  • the optimistic value is replaced with the server-confirmed value
  • if something went wrong, the temporary optimistic state is rolled back

Try It Again

Now drag a task from one column to another.

This time, the card should feel like it lands in the destination column right away, because the local query result updates immediately after the drop.

Checkpoint: Commit your progress.

git add .
git commit -m "kanban-14: Smooth drag and drop with optimistic updates"