Skip to content

createMutation

createMutation creates a reusable mutation object with pre-configured callbacks and invalidation logic. Perfect for update/insert operations that need to invalidate related queries.

import { createMutation } from "@ic-reactor/react"
import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const createPostMutation = createMutation(backend, {
functionName: "createPost",
invalidateQueries: [backend.generateQueryKey({ functionName: "getPosts" })],
})
OptionTypeDescription
functionNamestringThe canister method to call (Required)
callConfigCallConfigIC call configuration
invalidateQueriesQueryKey[]Queries to invalidate on success
onSuccess(data, variables, context) => voidCalled on success
onError(error, variables, context) => voidCalled on any error (network or canister)
onCanisterError(error, variables) => voidCalled specifically on canister logic error
onSettled(data, error, variables, context) => voidCalled after mutation
onMutate(variables) => Promise<context>Called before mutation (optimistic updates)

The onCanisterError callback is designed to handle business logic errors returned by the canister (e.g., Result.Err), separate from network or system errors.

  • Specific Targeting: onError catches everything (network issues, timeouts, throw errors). onCanisterError only fires when the canister executes successfully but returns an error result (e.g., InsufficientFunds).
  • Typed Errors: The error object passed to this callback is a typed CanisterError, making it easier to check specific error variants.
const transferMutation = createMutation(backend, {
functionName: "transfer",
onCanisterError: (error, args) => {
// 'error' is a CanisterError<T>
console.log("Error Key:", error.code) // e.g., "InsufficientFunds"
console.log("Error Data:", error.err) // The generic data associated with the error
if (error.code === "InsufficientFunds") {
toast.error("You don't have enough tokens!")
} else {
toast.error(`Transfer failed: ${error.code}`)
}
},
// 'onError' will still be called for network errors OR canister errors
onError: (error) => {
if (error instanceof CanisterError) {
// Already handled above
return
}
toast.error("Network error: " + error.message)
},
})
PropertyTypeDescription
useMutation(options?) => UseMutationResultReact hook for components
execute(args) => Promise<T>Direct execution (for utilities)

Combine mutations with TanStack Router’s action handlers:

routes/posts/new.tsx
import { createFileRoute } from "@tanstack/react-router"
import { createMutation } from "@ic-reactor/react"
import { backend } from "../../reactor"
const createPostMutation = createMutation(backend, {
functionName: "createPost",
invalidateQueries: [
backend.generateQueryKey({ functionName: "getPosts" }),
],
})
export const Route = createFileRoute("/posts/new")({
component: NewPostPage,
})
function NewPostPage() {
const navigate = Route.useNavigate()
const { mutate, isPending, error } = createPostMutation.useMutation({
onSuccess: (newPost) => {
// Navigate to the new post after creation
navigate({ to: `/posts/${newPost.id}` })
},
})
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutate([{
title: formData.get("title") as string,
content: formData.get("content") as string,
}])
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Content" required />
{error && <p className="error">{error.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
)
}

Update UI immediately before the canister responds:

const likePostMutation = createMutation(backend, {
functionName: "likePost",
onMutate: async (args) => {
const [postId] = args
const queryKey = getPostQuery(postId).getQueryKey()
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey })
// Snapshot current value
const previousPost = queryClient.getQueryData(queryKey)
// Optimistically update
queryClient.setQueryData(queryKey, (old: Post) => ({
...old,
likes: old.likes + 1,
isLiked: true,
}))
// Return context with snapshot
return { previousPost }
},
onError: (err, args, context) => {
// Rollback on error
const [postId] = args
if (context?.previousPost) {
queryClient.setQueryData(
getPostQuery(postId).getQueryKey(),
context.previousPost
)
}
},
onSettled: (data, error, args) => {
// Refetch to ensure consistency
const [postId] = args
queryClient.invalidateQueries({
queryKey: getPostQuery(postId).getQueryKey(),
})
},
})

Integrate with React Hook Form:

import { useForm } from "react-hook-form"
interface TransferForm {
recipient: string
amount: string
}
const transferMutation = createMutation(backend, {
functionName: "transfer",
invalidateQueries: [
backend.generateQueryKey({ functionName: "getBalance" }),
],
})
function TransferPage() {
const { register, handleSubmit, reset, formState } = useForm<TransferForm>()
const { mutate, isPending } = transferMutation.useMutation({
onSuccess: () => {
reset()
toast.success("Transfer successful!")
},
onError: (error) => {
toast.error(`Transfer failed: ${error.message}`)
},
})
const onSubmit = (data: TransferForm) => {
mutate([data.recipient, data.amount])
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("recipient", { required: true })} placeholder="Recipient" />
<input {...register("amount", { required: true })} placeholder="Amount" />
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
</form>
)
}

Execute mutations from utilities or event handlers:

const withdrawMutation = createMutation(backend, {
functionName: "withdraw",
})
// In an event handler or utility function
async function handleWithdraw(amount: bigint) {
try {
const result = await withdrawMutation.execute([amount])
console.log("Withdrawal successful:", result)
return result
} catch (error) {
console.error("Withdrawal failed:", error)
throw error
}
}
// Use in onClick
<button onClick={() => handleWithdraw(BigInt(100))}>
Withdraw
</button>

You can provide a list of query keys OR a function that returns them based on the mutation arguments.

Option 1: Static Keys (if args are known in advance)

Section titled “Option 1: Static Keys (if args are known in advance)”
const createCommentMutation = createMutation(backend, {
functionName: "createComment",
invalidateQueries: [
backend.generateQueryKey({ functionName: "getUserActivity" }),
],
})

To keep your code clean and reusable, define helper functions for your query keys:

// Define reusable query key helpers
const getPostKey = (postId: string) =>
backend.generateQueryKey({ functionName: "getPost", args: [postId] })
const getCommentsKey = (postId: string) =>
backend.generateQueryKey({ functionName: "getComments", args: [postId] })
const createCommentMutation = createMutation(backend, {
functionName: "createComment",
})
// In your component
function CommentForm({ postId }) {
const queryClient = useQueryClient()
const { mutate } = createCommentMutation.useMutation({
// Invalidate the post and comments after mutation
invalidateQueries: [getPostKey(postId), getCommentsKey(postId)],
})
// ...
}

If you are using createQuery (or other query factories), you can use their getQueryKey method directly:

import { createQuery, createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const postsQuery = createQuery(backend, {
functionName: "getPosts",
})
const createPostMutation = createMutation(backend, {
functionName: "createPost",
invalidateQueries: [
// Use the factory's getQueryKey method
postsQuery.getQueryKey(),
],
})

Factory callbacks run first, then hook callbacks:

// Factory level - runs first
const updateProfileMutation = createMutation(backend, {
functionName: "updateProfile",
onSuccess: () => {
analytics.track("profile_updated")
},
})
// Hook level - runs second
const { mutate } = updateProfileMutation.useMutation({
onSuccess: () => {
toast.success("Profile updated!")
},
})

Build comprehensive loading states:

function DeleteButton({ postId }: { postId: string }) {
const {
mutate,
isPending,
isSuccess,
error,
reset
} = deletePostMutation.useMutation()
if (isSuccess) {
return <span className="success">Deleted!</span>
}
return (
<>
<button
onClick={() => mutate([postId])}
disabled={isPending}
className={isPending ? "deleting" : ""}
>
{isPending ? "Deleting..." : "Delete"}
</button>
{error && (
<div className="error">
<span>{error.message}</span>
<button onClick={reset}>Dismiss</button>
</div>
)}
</>
)
}

Always specify which queries should be invalidated:

// ✅ Good - explicitly invalidate related data
const mutation = createMutation(backend, {
functionName: "createPost",
invalidateQueries: [
backend.generateQueryKey({ functionName: "getPosts" }),
backend.generateQueryKey({ functionName: "getPostCount" }),
],
})
// ❌ Bad - no invalidation, cache becomes stale
const mutation = createMutation(backend, {
functionName: "createPost",
})

Always provide user feedback on errors:

const { mutate, error } = mutation.useMutation({
onError: (error) => toast.error(error.message),
})
{error && <ErrorMessage error={error} />}

Disable buttons and show loading indicators:

<button disabled={isPending}>
{isPending ? <Spinner /> : "Submit"}
</button>