Signature
Access useMutation through the query caller's dot-notation API. Variables are passed to mutate(), not to the hook:
const result = q.users.useMutation("create", options)
// q.<group>.useMutation(method, options?)
// group — endpoint group name (e.g. "users")
// method — method name within the group (e.g. "create")
// options — { requestOptions?, ...reactQueryMutationOptions }
// Returns: UseMutationResult with typed mutate function
// result.mutate(variables) — fire and forget
// result.mutateAsync(variables) — returns Promise<R>Variables go to mutate(), not the hook
Unlike useQuery where variables are passed in the options object, useMutation receives variables through the mutate(variables) call. This follows the standard React Query mutation pattern.
Create mutation
The most common pattern — submit a form and create a resource. The mutate function is typed based on the method's variable type:
"use client"
import { q } from "@/lib/query"
export function CreateUserForm() {
// Method<User, { name: string; email: string; role: string }>
const { mutate, isPending } = q.users.useMutation("create", {
onSuccess: (user) => {
console.log("Created:", user.name) // ← user is typed as User
},
onError: (error) => {
console.error("Failed:", error.message)
},
})
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = new FormData(e.currentTarget)
mutate({
name: form.get("name") as string,
email: form.get("email") as string,
role: "user",
})
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
</form>
)
}Update mutation
For updates, pass all required fields (including the ID for path parameters) to mutate(). Courier extracts path parameters automatically:
"use client"
import { q } from "@/lib/query"
export function EditUserForm({ user }: { user: User }) {
// Method<User, { id: number; name: string; email: string }>
const { mutate, isPending } = q.users.useMutation("update")
const handleSave = (name: string, email: string) => {
// Variables passed to mutate(), not to the hook options
mutate({
id: user.id,
name,
email,
})
// ^? TypeScript enforces { id: number; name: string; email: string }
}
return (
<button onClick={() => handleSave("Jane", "jane@test.com")} disabled={isPending}>
Save
</button>
)
}Delete mutation
Delete methods typically return null and only need an ID. The variable type is inferred from the method definition:
"use client"
import { q } from "@/lib/query"
export function DeleteUserButton({ userId }: { userId: number }) {
// Method<null, { id: number }> → mutate takes { id: number }
const { mutate, isPending } = q.users.useMutation("remove", {
onSuccess: () => {
console.log("User deleted")
},
})
return (
<button onClick={() => mutate({ id: userId })} disabled={isPending}>
{isPending ? "Deleting..." : "Delete"}
</button>
)
}Cache invalidation
After a mutation succeeds, invalidate related queries to trigger a refetch. Use the hierarchical query key format [source, group, method?, variables?]:
"use client"
import { useQueryClient } from "@noverax/courier-react"
import { q } from "@/lib/query"
export function CreateUserForm() {
const queryClient = useQueryClient()
const { mutate } = q.users.useMutation("create", {
onSuccess: () => {
// Invalidate a specific method
queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })
// Or invalidate the entire users group
// queryClient.invalidateQueries({ queryKey: ["api", "users"] })
},
})
return (
<button onClick={() => mutate({ name: "Jane", email: "jane@test.com", role: "user" })}>
Create
</button>
)
}Group-level invalidation
Use ["api", "users"] to invalidate all queries in the users group at once — getAll, getById, and any other method. Use ["api"] to invalidate every query for that source.
Optimistic updates
For instant UI feedback, update the cache optimistically before the server responds. Roll back on error, and always refetch on settle to ensure consistency:
"use client"
import { useQueryClient } from "@noverax/courier-react"
import { q } from "@/lib/query"
type User = { id: number; name: string; email: string }
export function UserManager() {
const queryClient = useQueryClient()
const { data: users } = q.users.useQuery("getAll")
const { mutate: updateUser } = q.users.useMutation("update", {
// 1. Cancel outgoing refetches so they don't overwrite our optimistic update
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ["api", "users", "getAll"] })
// 2. Snapshot the previous value
const previous = queryClient.getQueryData<User[]>(["api", "users", "getAll"])
// 3. Optimistically update the cache
queryClient.setQueryData<User[]>(["api", "users", "getAll"], (old) =>
old?.map((u) => (u.id === variables.id ? { ...u, ...variables } : u))
)
return { previous }
},
// 4. On error, roll back to the previous value
onError: (_error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(["api", "users", "getAll"], context.previous)
}
},
// 5. Always refetch after error or success to ensure consistency
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })
},
})
return (
<ul>
{users?.map((user) => (
<li key={user.id}>
{user.name}
<button onClick={() => updateUser({ id: user.id, name: "Updated", email: user.email })}>
Update
</button>
</li>
))}
</ul>
)
}What onMutate returns reaches onError and onSettled typed as it was returned, so context.previous above is a User[] | undefined. TypeScript reads the object from top to bottom: write onMutate before the callbacks that use its result.
Next steps
- useQuery — fetch data with type-safe queries
- Advanced Patterns — prefetching, hydration, infinite queries, and more
- Server Actions — mutations using Server Actions instead of proxy mode
- Error Handling — handling mutation errors and HTTP status codes