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]
},
})
},
})
Helpers for updating non-paginated queries.
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]
},
})
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 },
})
Remove items from an array query.
import { deleteFromQuery } from '#imports'
deleteFromQuery({
query: api.todos.list,
args: {},
localQueryStore: localStore,
shouldDelete: (todo) => todo._id === args.todoId,
})
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,
})
Special helpers for paginated queries.
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,
},
})
},
})
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,
},
})
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,
},
})
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
},
})
Remove items from paginated results.
import { deleteFromPaginatedQuery } from '#imports'
deleteFromPaginatedQuery({
paginatedQuery: api.messages.list,
localQueryStore: localStore,
shouldDelete: (msg) => msg._id === args.messageId,
})
<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>
<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>
Always use unique temporary IDs for optimistic items:
const optimisticId = crypto.randomUUID() as Id<'todos'>
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 ?? []),
],
})
updater: (current) => {
if (!current) return current // Don't crash if query not loaded
return current.map(item => /* transform */)
}
// 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) => /* ... */,
})
// 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')
}
}
// 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]
}
// 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,
},
})