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: "create_post",
invalidateQueries: [{ functionName: "get_posts" }],
})
Option Type Description
functionName string The canister method to call (Required)
callConfig CallConfig IC call configuration
invalidateQueries InvalidationTarget[] Queries to invalidate on success
onSuccess (data, variables, onMutateResult) => void Called on success
onError (error, variables, onMutateResult) => void Called on any error (network or canister)
onCanisterError (error, variables) => void Called specifically on canister logic error
onSettled (data, error, variables, onMutateResult) => void Called after mutation
onMutate (variables) => onMutateResult | Promise<onMutateResult> 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.
import { CanisterError, createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
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: "create_post",
invalidateQueries: [{ functionName: "get_posts" }],
})
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. The value onMutate returns reaches the onError defined at the same level, so the snapshot and the rollback stay together. This example keeps both in useMutation():

import { createMutation, createQueryFactory } from "@ic-reactor/react"
import { backend } from "./reactor"
const likePostMutation = createMutation(backend, {
functionName: "like_post",
})
// A query factory takes the args tuple, as in `getPostQuery([postId])`
const getPostQuery = createQueryFactory(backend, { functionName: "get_post" })
// In your component
function LikeButton({ postId }: { postId: string }) {
const { mutate } = likePostMutation.useMutation({
// Cancel outgoing refetches, write the new value, and return the rollback.
// `post` is typed from the query; likes is a nat, which a DisplayReactor
// shows as a string.
onMutate: ([postId]) =>
getPostQuery([postId]).optimisticUpdate((post) => ({
...post,
likes: String(BigInt(post.likes) + 1n),
isLiked: true,
})),
// Rollback on error
onError: (_error, _args, update) => update?.rollback(),
// Refetch to ensure consistency: a failed call may still have run
onSettled: (_data, _error, [postId]) => getPostQuery([postId]).invalidate(),
})
return <button onClick={() => mutate([postId])}>Like</button>
}

optimisticUpdate skips the update when nothing is cached for the query, and rollback() restores the value it replaced with its original fetch time. A factory-level onMutate works the same way, and execute() runs it too.

Integrate with React Hook Form:

import { useForm } from "react-hook-form"
import { createMutation, parseTokenAmount } from "@ic-reactor/react"
import { backend } from "./reactor"
interface TransferForm {
recipient: string
amount: string
}
const transferMutation = createMutation(backend, {
functionName: "transfer",
invalidateQueries: [{ functionName: "get_balance" }],
})
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) => {
// A DisplayReactor takes the nat as text, in base units
mutate([data.recipient, parseTokenAmount(data.amount, 8).toString()])
}
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:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const withdrawMutation = createMutation(backend, {
functionName: "withdraw",
})
// In an event handler or utility function
async function handleWithdraw(amount: string) {
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
function WithdrawButton() {
return <button onClick={() => handleWithdraw("100")}>Withdraw</button>
}

execute() runs the mutation in the QueryClient’s MutationCache, as useMutation() does, with the factory’s options. The factory’s chain runs in the same order as on the hook path: onMutate, then on success the factory’s invalidateQueries and its onSuccess, or on failure its onCanisterError (for canister Err variants) and its onError, and then onSettled. A failed call then rethrows the error, so await execute(...) still rejects for the caller. Hook-level callbacks are absent because there is no hook on this path.

Because the mutation is in the MutationCache, the rest of TanStack Query sees it too: the MutationCache’s global onError, onSuccess and onSettled run for it, useIsMutating and useMutationState include it, and the factory’s retry, networkMode and scope, and the QueryClient’s mutation defaults, apply to it. That includes a mutations.retry default: it now re-sends a failed execute() call as it does a useMutation() one, and each retry of an update method is a new call that the canister runs, so a lost response can execute it twice. Set retry on the factory to decide per method, for example retry: false, or retry: reactorUpdateRetry, which retries only a SysTransient rejection, the one failure that proves the call never ran. With the default networkMode: "online", a call made while TanStack Query considers the browser offline pauses, like a useMutation() call, until the connection is back and a mounted QueryClient resumes it (any IC Reactor hook, or a QueryClientProvider, mounts one). Set networkMode: "always" on the factory for a local replica you can reach offline. The settled mutation stays in the cache for its gcTime, which TanStack Query sets to 5 minutes in a browser and to no limit on a server, so create the QueryClient per request on a server, as for queries.

invalidateQueries is a list, never a function of the mutation arguments. Supply it on the factory when the queries are known up front, or on useMutation() when they depend on component state. The hook’s list is invalidated after the factory’s. Each entry is one of:

Entry Invalidates
{ functionName } Every query of that method of this reactor, whatever its args
{ functionName, args } That method’s queries for those args, infinite lists aside
A query object That query, from any create*Query (suspense and infinite included)
A query factory Every query it returns, whatever its args, from any create*Factory
A QueryKey The queries under that key
undefined Nothing, so [maybeQuery] is safe

The method name and args of a { functionName, args? } entry are type-checked against the canister, and its key is built when the mutation succeeds, so it follows a setCanisterId. It names queries of the canister the mutation was sent to, which is the one in the mutation’s callConfig.canisterId when it sets one. An infinite list keys its pages apart from its args, so name one by its query object or factory, or by the method alone. A query object or factory is invalidated in its own reactor’s QueryClient.

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const createCommentMutation = createMutation(backend, {
functionName: "create_comment",
invalidateQueries: [
{ functionName: "get_user_activity" },
{ functionName: "get_comments" }, // every post's comments
],
})

When the queries depend on props, pass the query objects to useMutation():

import { createMutation, createQueryFactory } from "@ic-reactor/react"
import { backend } from "./reactor"
const getPost = createQueryFactory(backend, { functionName: "get_post" })
const getComments = createQueryFactory(backend, {
functionName: "get_comments",
})
const createCommentMutation = createMutation(backend, {
functionName: "create_comment",
})
// In your component
function CommentForm({ postId }: { postId: string }) {
const { mutate } = createCommentMutation.useMutation({
// Invalidate this post and its comments after the mutation
invalidateQueries: [getPost([postId]), getComments([postId])],
})
// ...
}

Pass a query object or a query factory as it is:

import { createQuery, createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const postsQuery = createQuery(backend, {
functionName: "get_posts",
})
const createPostMutation = createMutation(backend, {
functionName: "create_post",
invalidateQueries: [postsQuery],
})

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:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
// Factory level - runs first
const updateProfileMutation = createMutation(backend, {
functionName: "update_profile",
onSuccess: () => {
analytics.track("profile_updated")
},
})
// Hook level - runs second
function SaveProfileButton() {
const { mutate } = updateProfileMutation.useMutation({
onSuccess: () => {
// `toast` is your notification helper, such as sonner's
toast.success("Profile updated!")
},
})
return <button onClick={() => mutate([{ name: "Alice" }])}>Save</button>
}

Each level’s callbacks receive the return value of that level’s own onMutate. onSuccess and onError get it as their third argument, and onSettled gets it as its fourth, after data, error and variables. Factory callbacks get the factory’s result and hook callbacks get the hook’s, so rollback state can live at either level. A factory without its own onMutate hands its callbacks the hook’s result.

execute() runs the factory’s onMutate as well, and hands its result to the factory’s callbacks of the same call, so a factory onError can roll back there too. They get undefined when the factory has no onMutate, since there is no hook result on that path, and onError and onSettled get undefined when onMutate throws.

Build comprehensive loading states:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const deletePostMutation = createMutation(backend, {
functionName: "delete_post",
})
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:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
// ✅ Good - explicitly invalidate related data
const mutation = createMutation(backend, {
functionName: "create_post",
invalidateQueries: [
{ functionName: "get_posts" },
{ functionName: "get_posts_count" },
],
})
// ❌ Bad - keys start with the canister ID, so this matches nothing
const matchesNothing = createMutation(backend, {
functionName: "create_post",
invalidateQueries: [["get_posts"]],
})
// ❌ Bad - no invalidation, cache becomes stale
const goesStale = createMutation(backend, {
functionName: "create_post",
})

Always provide user feedback on errors:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const mutation = createMutation(backend, { functionName: "create_post" })
function CreatePostError() {
const { mutate, error } = mutation.useMutation({
onError: (error) => toast.error(error.message),
})
return error && <ErrorMessage error={error} />
}

Disable buttons and show loading indicators:

import { createMutation } from "@ic-reactor/react"
import { backend } from "./reactor"
const mutation = createMutation(backend, { functionName: "create_post" })
function SubmitButton() {
const { isPending } = mutation.useMutation()
return (
<button disabled={isPending}>{isPending ? <Spinner /> : "Submit"}</button>
)
}