Mutations

Optimistic Updates

Instant UI feedback with automatic rollback on failure.

How It Works

  1. User action: User clicks button/submits form
  2. Optimistic update: UI updates immediately with expected result
  3. Server request: Mutation sent to Convex
  4. Resolution:
    • Success: Server data replaces optimistic data
    • Failure: Optimistic changes rolled back automatically

Basic Example

const { execute } = useConvexMutation(api.todos.create, {
  optimisticUpdate: (localStore, args) => {
    // Update local query cache immediately
    updateQuery({
      query: api.todos.list,
      args: {},
      store: localStore,
      updater: (current) => {
        const optimisticTodo = {
          _id: crypto.randomUUID() as Id<'todos'>,
          _creationTime: Date.now(),
          text: args.text,
          completed: false,
        }
        return current ? [optimisticTodo, ...current] : [optimisticTodo]
      },
    })
  },
})

Regular Query Helpers

Helpers for updating non-paginated queries.

updateQuery

Update a query result with an updater function.

import { updateQuery } from '#imports'

updateQuery({
  query: api.todos.list, // Query to update
  args: {}, // Args to match
  store: localStore, // From optimistic context
  updater: (current) => {
    // Transform function
    const newTodo = { _id: crypto.randomUUID(), text: args.text }
    return current ? [newTodo, ...current] : [newTodo]
  },
})

setQueryData

Set query data directly without transformation.

import { setQueryData } from '#imports'

setQueryData({
  query: api.users.get,
  args: { userId: args.userId },
  store: localStore,
  value: { ...currentUser, name: args.newName },
})

deleteFromQuery

Remove items from an array query.

import { deleteFromQuery } from '#imports'

deleteFromQuery({
  query: api.todos.list,
  args: {},
  store: localStore,
  shouldDelete: (todo) => todo._id === args.todoId,
})

updateAllQueries

Update multiple query instances.

import { updateAllQueries } from '#imports'

updateAllQueries({
  query: api.users.get,
  argsToMatch: { userId: args.userId }, // Optional filter
  store: localStore,
  updater: (current) => (current ? { ...current, name: args.name } : undefined),
})

Paginated Query Helpers

Special helpers for paginated queries.

insertAtTop

Insert item at the top of paginated results. Use for newest-first lists.

import { insertAtTop } from '#imports'

const { execute } = useConvexMutation(api.messages.send, {
  optimisticUpdate: (localStore, args) => {
    insertAtTop({
      query: api.messages.list,
      store: localStore,
      item: {
        _id: crypto.randomUUID() as Id<'messages'>,
        _creationTime: Date.now(),
        body: args.body,
        authorId: currentUser._id,
      },
    })
  },
})

insertAtPosition

Insert item at correct sorted position.

import { insertAtPosition } from '#imports'

insertAtPosition({
  query: api.tasks.byPriority,
  sortOrder: 'desc',
  sortKeyFromItem: (task) => task.priority,
  store: localStore,
  item: {
    _id: crypto.randomUUID() as Id<'tasks'>,
    _creationTime: Date.now(),
    title: args.title,
    priority: args.priority,
  },
})

insertAtBottomIfLoaded

Insert at bottom only if all pages are loaded.

import { insertAtBottomIfLoaded } from '#imports'

insertAtBottomIfLoaded({
  query: api.messages.listOldestFirst,
  store: localStore,
  item: {
    _id: crypto.randomUUID() as Id<'messages'>,
    _creationTime: Date.now(),
    body: args.body,
  },
})

updateInPaginatedQuery

Update items in paginated results.

import { updateInPaginatedQuery } from '#imports'

updateInPaginatedQuery({
  query: api.tasks.list,
  store: localStore,
  updateValue: (task) => {
    if (task._id === args.taskId) {
      return { ...task, completed: !task.completed }
    }
    return task
  },
})

deleteFromPaginatedQuery

Remove items from paginated results.

import { deleteFromPaginatedQuery } from '#imports'

deleteFromPaginatedQuery({
  query: api.messages.list,
  store: localStore,
  shouldDelete: (msg) => msg._id === args.messageId,
})

Complete Examples

Todo App

<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
import type { Id } from '~~/convex/_generated/dataModel'

const { data: todos } = await useConvexQuery(api.todos.list, {})

// Add todo with optimistic update
const { execute: addTodo, pending: isAdding } = useConvexMutation(api.todos.create, {
  optimisticUpdate: (localStore, args) => {
    updateQuery({
      query: api.todos.list,
      args: {},
      store: localStore,
      updater: (current) => {
        const optimistic = {
          _id: crypto.randomUUID() as Id<'todos'>,
          _creationTime: Date.now(),
          text: args.text,
          completed: false,
        }
        return current ? [optimistic, ...current] : [optimistic]
      },
    })
  },
})

// Toggle todo with optimistic update
const { execute: toggleTodo } = useConvexMutation(api.todos.toggle, {
  optimisticUpdate: (localStore, args) => {
    updateQuery({
      query: api.todos.list,
      args: {},
      store: localStore,
      updater: (current) => {
        if (!current) return current
        return current.map((todo) =>
          todo._id === args.id ? { ...todo, completed: !todo.completed } : todo,
        )
      },
    })
  },
})

// Delete todo with optimistic update
const { execute: deleteTodo } = useConvexMutation(api.todos.remove, {
  optimisticUpdate: (localStore, args) => {
    deleteFromQuery({
      query: api.todos.list,
      args: {},
      store: localStore,
      shouldDelete: (todo) => todo._id === args.id,
    })
  },
})

const newTodoText = ref('')

async function handleAdd() {
  if (!newTodoText.value.trim()) return
  await addTodo({ text: newTodoText.value })
  newTodoText.value = ''
}
</script>

<template>
  <div class="todo-app">
    <form @submit.prevent="handleAdd">
      <input v-model="newTodoText" :disabled="isAdding" />
      <button :disabled="isAdding">Add</button>
    </form>

    <ul>
      <li v-for="todo in todos" :key="todo._id">
        <input type="checkbox" :checked="todo.completed" @change="toggleTodo({ id: todo._id })" />
        <span :class="{ completed: todo.completed }">{{ todo.text }}</span>
        <button @click="deleteTodo({ id: todo._id })">Delete</button>
      </li>
    </ul>
  </div>
</template>

Chat with Paginated Messages

<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
import type { Id } from '~~/convex/_generated/dataModel'

const { user } = useConvexAuth()

const {
  results: messages,
  status,
  loadMore,
} = await useConvexPaginatedQuery(
  api.messages.list,
  { channelId: props.channelId },
  { initialNumItems: 50 },
)

// Send message with optimistic update
const { execute: sendMessage, pending } = useConvexMutation(api.messages.send, {
  optimisticUpdate: (localStore, args) => {
    insertAtTop({
      query: api.messages.list,
      argsToMatch: { channelId: args.channelId },
      store: localStore,
      item: {
        _id: crypto.randomUUID() as Id<'messages'>,
        _creationTime: Date.now(),
        body: args.body,
        channelId: args.channelId,
        authorId: user.value?.id as Id<'users'>,
        authorName: user.value?.name ?? 'You',
      },
    })
  },
})

// Delete message with optimistic update
const { execute: deleteMessage } = useConvexMutation(api.messages.delete, {
  optimisticUpdate: (localStore, args) => {
    deleteFromPaginatedQuery({
      query: api.messages.list,
      argsToMatch: { channelId: props.channelId },
      store: localStore,
      shouldDelete: (msg) => msg._id === args.messageId,
    })
  },
})

const messageText = ref('')

async function handleSend() {
  if (!messageText.value.trim()) return
  await sendMessage({
    channelId: props.channelId,
    body: messageText.value,
  })
  messageText.value = ''
}
</script>

<template>
  <div class="chat">
    <div class="messages">
      <div v-for="msg in messages" :key="msg._id" class="message">
        <strong>{{ msg.authorName }}</strong>
        <p>{{ msg.body }}</p>
        <button v-if="msg.authorId === user?.id" @click="deleteMessage({ messageId: msg._id })">
          Delete
        </button>
      </div>
      <button v-if="status === 'ready'" @click="loadMore(50)">Load older messages</button>
    </div>

    <form @submit.prevent="handleSend" class="compose">
      <input v-model="messageText" :disabled="pending" />
      <button :disabled="pending">Send</button>
    </form>
  </div>
</template>

Best Practices

1. Generate temporary IDs

Always use unique temporary IDs for optimistic items:

const optimisticId = crypto.randomUUID() as Id<'todos'>

2. Include all required fields

Optimistic items should match the query return shape:

// If your query returns these fields...
{
  _id: Id<'todos'>,
  _creationTime: number,
  text: string,
  completed: boolean,
  userId: Id<'users'>,
}

// ...include them all in optimistic update
updateQuery({
  // ...
  updater: (current) => [
    {
      _id: crypto.randomUUID() as Id<'todos'>,
      _creationTime: Date.now(),
      text: args.text,
      completed: false,
      userId: currentUserId,  // Include all fields!
    },
    ...(current ?? []),
  ],
})

3. Handle missing data gracefully

updater: (current) => {
  if (!current) return current  // Don't crash if query not loaded
  return current.map(item => /* transform */)
}

4. Match query args exactly

// If your query uses these args...
const { data } = await useConvexQuery(api.todos.list, { status: 'active' })

// ...match them in optimistic update
updateQuery({
  query: api.todos.list,
  args: { status: 'active' },  // Must match!
  store: localStore,
  updater: (current) => /* ... */,
})

Common Mistakes

1. Forgetting to handle rollback

// WRONG: Assuming mutation will succeed
const { execute } = useConvexMutation(api.todos.create, {
  optimisticUpdate: (localStore, args) => {
    // Update happens...
  },
})

async function handleCreate() {
  await execute({ text }) // If this fails, UI auto-rollbacks
  toast.success('Created!') // But toast shows anyway!
}

// RIGHT: Handle errors properly
async function handleCreate() {
  try {
    await execute({ text })
    toast.success('Created!')
  } catch {
    toast.error('Failed to create')
  }
}

2. Modifying current value directly

// WRONG: Mutating the array
updater: (current) => {
  current?.push(newItem) // Don't mutate!
  return current
}

// RIGHT: Return new array
updater: (current) => {
  return current ? [...current, newItem] : [newItem]
}

3. Not matching paginated query shape

// WRONG: Missing required pagination fields
insertAtTop({
  query: api.messages.list,
  item: { body: args.body }, // Missing _id, _creationTime, etc.
})

// RIGHT: Include all fields
insertAtTop({
  query: api.messages.list,
  item: {
    _id: crypto.randomUUID() as Id<'messages'>,
    _creationTime: Date.now(),
    body: args.body,
    authorId: currentUser._id,
  },
})