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 { mutate } = useConvexMutation(api.todos.create, {
  optimisticUpdate: (localStore, args) => {
    // Update local query cache immediately
    updateQuery({
      query: api.todos.list,
      args: {},
      localQueryStore: 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
  localQueryStore: 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 },
  localQueryStore: localStore,
  value: { ...currentUser, name: args.newName },
})

deleteFromQuery

Remove items from an array query.

import { deleteFromQuery } from '#imports'

deleteFromQuery({
  query: api.todos.list,
  args: {},
  localQueryStore: 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
  localQueryStore: 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 { mutate } = useConvexMutation(api.messages.send, {
  optimisticUpdate: (localStore, args) => {
    insertAtTop({
      paginatedQuery: api.messages.list,
      localQueryStore: 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({
  paginatedQuery: api.tasks.byPriority,
  sortOrder: 'desc',
  sortKeyFromItem: (task) => task.priority,
  localQueryStore: 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({
  paginatedQuery: api.messages.listOldestFirst,
  localQueryStore: localStore,
  item: {
    _id: crypto.randomUUID() as Id<'messages'>,
    _creationTime: Date.now(),
    body: args.body,
  },
})

optimisticallyUpdateValueInPaginatedQuery

Update items in paginated results.

import { optimisticallyUpdateValueInPaginatedQuery } from '#imports'

optimisticallyUpdateValueInPaginatedQuery({
  paginatedQuery: api.tasks.list,
  localQueryStore: 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({
  paginatedQuery: api.messages.list,
  localQueryStore: 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 } = useConvexQuery(api.todos.list, {})

// Add todo with optimistic update
const { mutate: addTodo, pending: isAdding } = useConvexMutation(
  api.todos.create,
  {
    optimisticUpdate: (localStore, args) => {
      updateQuery({
        query: api.todos.list,
        args: {},
        localQueryStore: 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 { mutate: toggleTodo } = useConvexMutation(
  api.todos.toggle,
  {
    optimisticUpdate: (localStore, args) => {
      updateQuery({
        query: api.todos.list,
        args: {},
        localQueryStore: 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 { mutate: deleteTodo } = useConvexMutation(
  api.todos.remove,
  {
    optimisticUpdate: (localStore, args) => {
      deleteFromQuery({
        query: api.todos.list,
        args: {},
        localQueryStore: 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 } = useConvexPaginatedQuery(
  api.messages.list,
  { channelId: props.channelId },
  { initialNumItems: 50 }
)

// Send message with optimistic update
const { mutate: sendMessage, pending } = useConvexMutation(
  api.messages.send,
  {
    optimisticUpdate: (localStore, args) => {
      insertAtTop({
        paginatedQuery: api.messages.list,
        argsToMatch: { channelId: args.channelId },
        localQueryStore: 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 { mutate: deleteMessage } = useConvexMutation(
  api.messages.delete,
  {
    optimisticUpdate: (localStore, args) => {
      deleteFromPaginatedQuery({
        paginatedQuery: api.messages.list,
        argsToMatch: { channelId: props.channelId },
        localQueryStore: 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 === 'CanLoadMore'" @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 } = useConvexQuery(api.todos.list, { status: 'active' })

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

Common Mistakes

1. Forgetting to handle rollback

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

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

// RIGHT: Handle errors properly
async function handleCreate() {
  try {
    await mutate({ 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({
  paginatedQuery: api.messages.list,
  item: { body: args.body },  // Missing _id, _creationTime, etc.
})

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