Mutations
Create, update, and delete data with type-safe mutations
Mutations
Any route using POST, PUT, PATCH, or DELETE is treated as a mutation procedure. Use mutationOptions() to create options for TanStack Query's useMutation.
Basic Mutation
import { useMutation } from '@tanstack/react-query'
import { useEden } from './lib/eden'
function CreateUserForm() {
const eden = useEden()
const createUser = useMutation(
eden.users.post.mutationOptions()
)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createUser.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
)
}Required and optional bodies
A declared object body is required even when every field is optional. For a schema such as t.Object({ name: t.Optional(t.String()) }), pass an object:
const updateUser = useMutation(eden.users.patch.mutationOptions())
updateUser.mutate({})
updateUser.mutate({ name: 'Ada' })If older code called mutate() or mutateAsync() for this schema, replace it with mutate({}) or mutateAsync({}). Omitting the body now fails type checking; it previously sent undefined, which the server could reject with a validation error.
Routes without a body and optional object bodies, such as t.Optional(t.Object({ name: t.String() })), still allow omitted variables. Their lifecycle callbacks must handle missing variables before reading body fields. The standalone edenMutationOptions factory allows omission when its input type includes undefined.
Some Elysia versions encode optional object bodies as Partial<Body> | null. The proxy also allows omission for this shape. A declared required nullable object with all optional fields has the same type, so the proxy cannot distinguish it from an optional body. Pass an object explicitly for that schema; server handling of an omitted body depends on Elysia. Optional primitive bodies can share their type with required nullable primitives; pass variables explicitly for those schemas.
Per-invocation query and headers
Pass request: true to send { body, query, headers } variables. Query values and headers are inferred from the Elysia route, including required fields. For example, this route requires a body, a query parameter, and an idempotency header:
const app = new Elysia().post(
'/jobs',
({ body, query }) => ({ name: body.name, dryRun: query.dryRun }),
{
body: t.Object({ name: t.String() }),
query: t.Object({ dryRun: t.Boolean() }),
headers: t.Object({ 'idempotency-key': t.String() }),
},
)const createJob = useMutation(
eden.jobs.post.mutationOptions({
request: true,
onMutate: (variables) => ({ key: variables.headers['idempotency-key'] }),
onError: (error, variables, context) => {
console.error(context?.key, variables.body.name, error)
},
}),
)
const submission = {
body: { name: 'Import contacts' },
query: { dryRun: false },
headers: { 'idempotency-key': crypto.randomUUID() },
}
await createJob.mutateAsync(submission)Create the key once per logical submission and reuse submission when retrying it. TanStack retries reuse the same variables. The server must implement idempotency handling; the header alone does not prevent duplicate writes.
When extracting request options into a variable, preserve the literal true type with satisfies:
const requestOptions = { request: true } satisfies { request: true }
const createJob = useMutation(eden.jobs.post.mutationOptions(requestOptions))A plain const requestOptions = { request: true } infers a mutable boolean field. mutationOptions rejects that type because the flag determines whether mutation variables are a body or a request object. For reusable options with callbacks, extract the result of mutationOptions({ request: true, ... }) so callback types are inferred at that call.
Lifecycle callbacks receive the complete variables object, and onMutate still infers the context passed to later callbacks. Bind dynamic path parameters before calling mutationOptions:
const deleteUser = useMutation(
eden.users({ id: userId }).delete.mutationOptions({ request: true }),
)
// A route without a body can still send per-call headers.
deleteUser.mutate({ headers: { 'if-match': revision } })The body field is required when the route requires a body, even if every body property is optional. Omit body for a bodyless route. You may omit the entire variables argument when the body, query, and headers are all optional. Request mode supports query values and headers; configure other request settings through Treaty.
Without request: true, variables remain the body. A body containing fields named body, query, or headers is sent unchanged. Both modes use the same mutation key.
Cache Invalidation
Invalidate related queries after a successful mutation using queryKey() or queryFilter():
import { useQueryClient, useMutation } from '@tanstack/react-query'
function CreateUserForm() {
const eden = useEden()
const queryClient = useQueryClient()
const createUser = useMutation({
...eden.users.post.mutationOptions(),
onSuccess: () => {
// Using queryKey
queryClient.invalidateQueries({
queryKey: eden.users.get.queryKey()
})
// Or using queryFilter
queryClient.invalidateQueries(
eden.users.get.queryFilter()
)
},
})
}Invalidate multiple related queries:
const createPost = useMutation({
...eden.posts.post.mutationOptions(),
onSuccess: (newPost) => {
queryClient.invalidateQueries({ queryKey: eden.posts.get.queryKey() })
queryClient.invalidateQueries({
queryKey: eden.users({ id: newPost.authorId }).posts.get.queryKey()
})
},
})Optimistic Updates
Update the UI immediately while the mutation runs in the background.
Pass the lifecycle callbacks inside mutationOptions(). Spreading the options and adding onMutate at the useMutation call site does not compile: the spread pins the mutation's context type before onMutate can infer it, so the context argument of onError becomes never.
const toggleTodo = useMutation(
eden.todos({ id: todo.id }).patch.mutationOptions({
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: eden.todos.get.queryKey() })
const previousTodos = queryClient.getQueryData(eden.todos.get.queryKey())
queryClient.setQueryData(
eden.todos.get.queryKey(),
(old: Todo[] | undefined) =>
old?.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t)
)
return { previousTodos }
},
onError: (_err, _newTodo, context) => {
queryClient.setQueryData(eden.todos.get.queryKey(), context?.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: eden.todos.get.queryKey() })
},
})
)Mutation Key
Get the mutation key for tracking mutation state:
import { useMutationState } from '@tanstack/react-query'
const eden = useEden()
// Get mutation key
const key = eden.users.post.mutationKey()
// Track pending mutations
const pendingMutations = useMutationState({
filters: {
mutationKey: eden.users.post.mutationKey(),
status: 'pending',
},
})Path Parameters
Path parameters work the same way as with queries:
// PUT /users/:id
const updateUser = useMutation(
eden.users({ id: userId }).put.mutationOptions()
)
updateUser.mutate({ name: 'New Name' })
// DELETE /users/:id
const deleteUser = useMutation(
eden.users({ id: userId }).delete.mutationOptions()
)
deleteUser.mutate()mutateAsync
Use mutateAsync for promise-based control flow:
const createUser = useMutation(eden.users.post.mutationOptions())
async function handleSubmit(data: CreateUserInput) {
try {
const user = await createUser.mutateAsync(data)
navigate(`/users/${user.id}`)
} catch (error) {
console.error('Failed:', error)
}
}