Skip to content
IC Reactor

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" })],
})
Option Type Description
functionName string The canister method to call (Required)
callConfig CallConfig IC call configuration
invalidateQueries QueryKey[] Queries to invalidate on success
onSuccess (data, variables, context) => void Called on success
onError (error, variables, context) => void Called on any error (network or canister)
onCanisterError (error, variables) => void Called specifically on canister logic error
onSettled (data, error, variables, context) => void Called 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)
},
})
Property Type Description
useMutation (options?) => UseMutationResult React hook for components
execute (args) => Promise<T> Imperative call that runs the factory-level callback chain

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. Pass onMutate to useMutation(), not to the factory: the context handed to onError is the return value of the hook-level onMutate — a factory-level onMutate runs, but its return value is not kept, so a snapshot taken there is lost:

const likePostMutation = createMutation(backend, {
functionName: "likePost",
})
// In your component
const { mutate } = likePostMutation.useMutation({
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>

execute() runs the same factory-level chain the hook path does. On success it awaits the factory’s invalidateQueries and then its onSuccess; on failure it calls the factory’s onCanisterError (for canister Err variants) followed by its onError, and then rethrows, so await execute(...) still rejects for the caller. Hook-level callbacks are absent because there is no hook on this path, and onMutate/onSettled do not run either — they belong to TanStack Query’s mutation lifecycle, which only useMutation() has.

invalidateQueries is always an array of query keys — never a function of the mutation arguments. Supply it on the factory when the keys are known up front, or on useMutation() when they depend on component state.

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 and hook callbacks chain — both run, factory first. This holds for all of them: onSuccess, onError, onCanisterError, onMutate, and onSettled. A hook-level callback never replaces the factory’s, so factory teardown, telemetry, or logging cannot silently vanish because a call site passed its own handler:

// 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!")
},
})

One asymmetry to know about: the mutation’s context — the value handed to onSuccess, onError, and onSettled — is the return value of the hook-level onMutate. A factory-level onMutate runs first but its return value is not kept, so snapshot rollback state (as in the optimistic-update example above) belongs in the onMutate you pass to useMutation().

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>