Mutations
Write data with defineMutation, database operations, and optimistic updates.
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const create = defineMutation({
args: { title: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert('todos', {
title: args.title,
completed: false,
createdAt: Date.now(),
})
},
})A mutation writes data to the database. Define mutations with defineMutation(), specifying arguments and a handler. The handler receives a context object with a database writer (ctx.db) and the validated arguments.
Mutations are server-authoritative. When online, the mutation runs on the server. When offline, the SyncEngine runs the handler locally for an optimistic update, then the mutation queues in the mutation log and replays on the server when connectivity returns.
Defining a mutation
Every mutation needs two properties:
args-- an object of validators that define the mutation's parametershandler-- an async function that receives(ctx, args)and performs database writes
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const create = defineMutation({
args: {
title: v.string(),
priority: v.optional(v.number()),
},
handler: async (ctx, args) => {
return ctx.db.insert('todos', {
title: args.title,
completed: false,
priority: args.priority ?? 0,
createdAt: Date.now(),
})
},
})Database writer operations
The context's database writer (ctx.db) provides four operations for modifying documents.
Insert
ctx.db.insert(table, document) creates a new document and returns its _id:
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const create = defineMutation({
args: { title: v.string() },
handler: async (ctx, args) => {
const id = await ctx.db.insert('todos', {
title: args.title,
completed: false,
})
return id
},
})Patch
ctx.db.patch(id, fields) merges fields into an existing document. Fields not included in the patch are left unchanged:
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const updateTitle = defineMutation({
args: {
id: v.id('todos'),
title: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { title: args.title })
},
})
export const toggle = defineMutation({
args: { id: v.id('todos') },
handler: async (ctx, args) => {
const todo = await ctx.db.get(args.id)
if (todo) {
await ctx.db.patch(args.id, { completed: !todo.completed })
}
},
})Replace
ctx.db.replace(id, document) replaces the entire document. All fields must be provided (except _id):
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const replace = defineMutation({
args: {
id: v.id('todos'),
title: v.string(),
completed: v.boolean(),
priority: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.replace(args.id, {
title: args.title,
completed: args.completed,
priority: args.priority,
})
},
})Delete
ctx.db.delete(id) removes a document:
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const remove = defineMutation({
args: { id: v.id('todos') },
handler: async (ctx, args) => {
await ctx.db.delete(args.id)
},
})Server-authoritative model
Mutations are always confirmed by the server. The flow depends on connectivity:
Online: The mutation is sent to the server. The server runs the handler, validates the writes, and responds. The client receives the confirmed result.
Offline: The SyncEngine runs the handler locally against client-side SQLite for an immediate optimistic update. The mutation is added to the mutation log (persisted in IndexedDB). On reconnect, each queued mutation replays against fresh server state. If the server confirms, the optimistic update stands. If the server rejects (validation failure, conflict), the optimistic update rolls back.
Using mutations in React
useMutation
useMutation returns a mutation object with a .mutate() method:
// TodoForm.tsx
import { useMutation } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function TodoForm() {
const createTodo = useMutation(api.todos.create)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = new FormData(e.currentTarget)
createTodo.mutate({ title: form.get('title') as string })
e.currentTarget.reset()
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="What needs doing?" />
<button type="submit" disabled={createTodo.isPending}>
Add
</button>
</form>
)
}Callbacks
Pass onSuccess, onError, and onSettled callbacks as options to useMutation(). The .mutate() method only takes the mutation arguments:
// TodoActions.tsx
import { useMutation } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function DeleteButton({ id }: { id: string }) {
const removeTodo = useMutation(api.todos.remove, {
onSuccess: () => {
console.log('Deleted')
},
onError: (error) => {
console.error('Failed to delete:', error.message)
},
onSettled: () => {
console.log('Mutation finished')
},
})
return (
<button onClick={() => removeTodo.mutate({ id })}>
Delete
</button>
)
}SyncError
When a mutation fails, the error is a SyncError with a code and message:
// ErrorExample.tsx
import { useMutation } from './_generated/valet/react'
import { api } from './_generated/valet/api'
import type { SyncError } from 'valet-dev'
function CreateButton() {
const createTodo = useMutation(api.todos.create, {
onError: (error: SyncError) => {
if (error.code === 'VALIDATION_ERROR') {
alert('Invalid input')
}
},
})
return (
<button onClick={() => createTodo.mutate({ title: 'New todo' })}>
Create
</button>
)
}Optimistic updates
For more control over how the UI updates before server confirmation, use useOptimisticMutation(). This lets you define an optimisticUpdate function that modifies the local query cache immediately:
// TodoList.tsx
import { useQuery, useOptimisticMutation } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function TodoList() {
const { data: todos } = useQuery(api.todos.list, {})
const createTodo = useOptimisticMutation(api.todos.create, {
queryKey: 'todos.list',
optimisticUpdate: (currentData, args) => [
...(currentData ?? []),
{
_id: crypto.randomUUID(),
title: args.title,
completed: false,
createdAt: Date.now(),
},
],
})
return (
<div>
<ul>
{todos?.map((todo) => (
<li key={todo._id}>{todo.title}</li>
))}
</ul>
<button onClick={() => createTodo.mutate({ title: 'New todo' })}>
Add
</button>
</div>
)
}The optimisticUpdate function receives the current cached data for the query identified by queryKey and the mutation arguments. It returns the new data to display optimistically. If the server rejects the mutation, the optimistic update rolls back and the query returns to its previous state.