Skip to content
IC Reactor

Mutations

Unlike queries, mutations are typically used to create, update, or delete data on your canister, or perform side-effects. For this purpose, IC Reactor provides the useActorMutation hook.

Here’s an example of a mutation that creates a new post:

import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const { mutate, isPending, isSuccess, isError, error } = useActorMutation({
functionName: "create_post",
})
return (
<button
onClick={() => {
mutate([{ title: "My New Post", content: "Hello world!" }])
}}
disabled={isPending}
>
{isPending ? "Creating..." : "Create Post"}
</button>
)
}

A mutation can only be in one of the following states at any given moment:

  • isIdle or status === 'idle' - The mutation is currently idle or in a fresh/reset state
  • isPending or status === 'pending' - The mutation is currently running
  • isError or status === 'error' - The mutation encountered an error
  • isSuccess or status === 'success' - The mutation was successful and mutation data is available

Beyond those primary states, more information is available depending on the state of the mutation:

  • error - If the mutation is in an isError state, the error is available via the error property.
  • data - If the mutation is in an isSuccess state, the return data is available via the data property.

Arguments are passed to the mutate function as an array (matching the canister method signature):

// Canister method: transfer(to: Principal, amount: nat64) -> Result<TransferReceipt, TransferError>
import { Principal } from "@icp-sdk/core/principal"
import { useActorMutation } from "../reactor/hooks"
function SendButton() {
const { mutate } = useActorMutation({
functionName: "transfer",
})
return (
<button
onClick={() => {
// Call with arguments as array, in raw Candid types
mutate([Principal.fromText("aaaaa-aa"), BigInt(1000000)])
}}
>
Send
</button>
)
}

useActorMutation comes with helper options that allow quick and easy side-effects at any stage during the mutation lifecycle:

import { useActorMutation } from "../reactor/hooks"
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
onMutate: (variables) => {
// A mutation is about to happen!
console.log("Creating post with:", variables)
// Optionally return a context containing data to use when rolling back
return { id: Date.now() }
},
onError: (error, variables, context) => {
// An error happened!
console.log("Rolling back with id:", context?.id)
},
onSuccess: (data, variables, context) => {
// Boom baby!
console.log("Post created:", data)
},
onSettled: (data, error, variables, context) => {
// Error or success... doesn't matter!
console.log("Mutation finished")
},
})
}

When returning a promise in any of the callback functions, it will first be awaited before the next callback is called:

import { useActorMutation } from "../reactor/hooks"
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
onSuccess: async () => {
console.log("I'm first!")
},
onSettled: async () => {
console.log("I'm second!")
},
})
}

You can also provide additional callbacks to the mutate function itself. These are useful for component-specific side effects:

import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const { mutate } = useActorMutation({
functionName: "create_post",
onSuccess: () => {
// This fires first (from hook config)
},
})
return (
<button
onClick={() =>
mutate([{ title: "New Post", content: "Hello world!" }], {
onSuccess: () => {
// This fires second (from mutate call)
navigate("/posts")
},
})
}
>
Create Post
</button>
)
}

The most common pattern after a mutation is to invalidate related queries. List them in the invalidateQueries option:

import { useActorMutation } from "../reactor/hooks"
function useCreatePost(postId: string) {
return useActorMutation({
functionName: "create_post",
invalidateQueries: [
// Every get_posts query, whatever its args, infinite lists included
{ functionName: "get_posts" },
// Only the get_post query for this id
{ functionName: "get_post", args: [postId] },
],
})
}

Each entry is one of:

  • a method of the mutation’s reactor, { functionName } or { functionName, args }. The method name and the args are type-checked against the canister, and the key is built when the mutation succeeds, for the canister the mutation was sent to (its callConfig.canisterId, if set);
  • a query object from createQuery, createSuspenseQuery, createInfiniteQuery or createSuspenseInfiniteQuery;
  • a query factory from createQueryFactory and the other create*Factory functions, which covers every query it returns, whatever its args;
  • a query key, from generateQueryKey or getQueryKey().

With query objects and factories defined in a shared module, pass them as they are:

import { useActorMutation } from "../reactor/hooks"
import { postsQuery, getPost } from "../queries"
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
invalidateQueries: [postsQuery, getPost],
})
}

The invalidation runs after the canister call succeeds and before onSuccess. The mutation stays pending until the invalidated queries that are in use have refetched, so onSuccess reads the refetched data. undefined entries are skipped, so [maybeQuery] is safe when a query is optional.

For anything a fixed list cannot express, invalidate in onSuccess, and await it so the mutation waits for the refetch:

import { backend } from "../reactor"
import { useActorMutation } from "../reactor/hooks"
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
onSuccess: async (post) => {
await backend.invalidateQueries({ functionName: "get_posts" })
navigate(`/posts/${post.id}`)
},
})
}

Here’s a complete example with form handling:

import { useState } from "react"
import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const { mutate, isPending, isError, error, reset } = useActorMutation({
functionName: "create_post",
onSuccess: () => {
// Clear form on success
setTitle("")
setContent("")
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutate([{ title, content }])
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
disabled={isPending}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Content"
disabled={isPending}
/>
{isError && (
<div className="error">
Error: {error.message}
<button type="button" onClick={reset}>
Dismiss
</button>
</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
)
}

If you prefer async/await syntax, use mutateAsync:

import { useActorMutation } from "../reactor/hooks"
function TransferButton() {
const { mutateAsync, isPending } = useActorMutation({
functionName: "transfer",
})
const handleTransfer = async () => {
try {
const result = await mutateAsync([recipient, amount])
console.log("Transfer successful:", result)
toast.success("Transfer complete!")
} catch (error) {
console.error("Transfer failed:", error)
toast.error("Transfer failed")
}
}
return (
<button onClick={handleTransfer} disabled={isPending}>
Transfer
</button>
)
}

Use the reset function to clear the mutation state:

import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const { mutate, isError, error, reset } = useActorMutation({
functionName: "create_post",
})
return (
<div>
{isError && (
<div>
<p>Error: {error.message}</p>
<button onClick={reset}>Clear error</button>
</div>
)}
<button onClick={() => mutate([{ title: "Test", content: "Test post" }])}>
Create Post
</button>
</div>
)
}

Show the result before the canister answers with a query object’s optimisticUpdate(). Return it from onMutate and roll back in onError:

import { createQueryFactory } from "@ic-reactor/react"
import { backend } from "../reactor"
import { useActorMutation } from "../reactor/hooks"
// Module scope, next to the reactor
const getPost = createQueryFactory(backend, { functionName: "get_post" })
function LikeButton({ postId }: { postId: string }) {
const { mutate } = useActorMutation({
functionName: "like_post",
// Cancels the post's fetch in flight, writes the new value, and resolves
// with a rollback. `post` is typed from getPost; likes is a nat (bigint).
onMutate: ([postId]) =>
getPost([postId]).optimisticUpdate((post) => ({
...post,
likes: post.likes + 1n,
})),
// The call failed: show the post as it was
onError: (_error, _args, update) => update?.rollback(),
// Refetch either way: a call that failed in transit may still have run
onSettled: (_data, _error, [postId]) => getPost([postId]).invalidate(),
})
return <button onClick={() => mutate([postId])}>Like</button>
}

optimisticUpdate works on the query’s raw data, before any select, and calls the updater only when a value is cached: with nothing on screen there is nothing to update. rollback() writes back the value from before the update with its original fetch time, and invalidates it again if it was invalidated. After a sign-in or sign-out it does nothing, since that value was the previous principal’s and the sign-in has already removed or refetched it.

Always refetch the query once the mutation settles: the fetch that optimisticUpdate cancels may be a refetch an invalidation or a sign-in started. onSettled above does that either way. To refetch only after a success, list the query in invalidateQueries: [getPost] instead.

It works on the reactor’s own QueryClient, so no QueryClientProvider is needed. useQueryClient() throws without one; when you need the client itself, use backend.queryClient.

For reusable mutation configurations, use createMutation:

import { createMutation } from "@ic-reactor/react"
import { backend } from "../reactor"
import type { PostInput } from "../declarations/backend"
// Define mutation once
const createPostMutation = createMutation(backend, {
functionName: "create_post",
invalidateQueries: [{ functionName: "get_posts" }],
})
// Use in components
function CreatePost() {
const { mutate, isPending } = createPostMutation.useMutation()
// ...
}
// Or execute directly (outside React)
async function createPostInLoader(data: PostInput) {
const result = await createPostMutation.execute([data])
return result
}

execute() behaves like the hook path at the factory level: it runs the factory’s onMutate, then its invalidateQueries and onSuccess on success, or its onCanisterError and onError on failure, then its onSettled, and rethrows a failure so the await still rejects. See createMutation for details.

IC Reactor provides structured error types for mutations:

import { CanisterError, CallError, formatTokenAmount } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
// The token's decimals: the balance below is in base units
const TOKEN_DECIMALS = 8
function useTransfer() {
return useActorMutation({
functionName: "transfer",
onError: (error) => {
if (error instanceof CanisterError) {
// Business logic error from canister (e.g., InsufficientFunds)
if ("InsufficientFunds" in error.err) {
const balance = formatTokenAmount(
error.err.InsufficientFunds.balance,
TOKEN_DECIMALS
)
toast.error(`Not enough funds. Balance: ${balance}`)
} else if ("InvalidRecipient" in error.err) {
toast.error("Invalid recipient address")
}
} else if (error instanceof CallError) {
// Network or agent error
toast.error("Network error. Please try again.")
}
},
})
}

Mutations retry nothing by default, as in TanStack Query, and that is the safe default for update methods: each attempt is a new call under a new request id, which the canister runs, so a retry after a lost response executes the update twice. Set retry only for an update that is safe to repeat, or use reactorUpdateRetry, which retries only a SysTransient rejection, the one failure that proves the call never ran:

import { reactorUpdateRetry } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
function useTransfer() {
return useActorMutation({
functionName: "transfer",
retry: reactorUpdateRetry,
})
}

A mutations.retry in the QueryClient’s defaultOptions applies to every mutation: useActorMutation, useActorMethod’s update calls, and createMutation through both useMutation() and execute(). Prefer reactorUpdateRetry there over a plain number.

Like queries, mutations are fully type-safe:

// Canister: create_post(input: PostInput) -> Result<Post, CreatePostError>
import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const { mutate } = useActorMutation({
functionName: "create_post", // ✅ Autocomplete available
})
return (
<button
onClick={() => {
// ✅ TypeScript knows the argument type
mutate([{ title: "Hello", content: "World" }])
}}
>
Create Post
</button>
)
}

Leave out a required field and the call no longer type-checks:

import { useActorMutation } from "../reactor/hooks"
function CreatePost() {
const { mutate } = useActorMutation({
functionName: "create_post",
})
return (
<button
onClick={() => {
// ❌ TypeScript error: property 'content' is missing
// @ts-expect-error
mutate([{ title: "Hello" }])
}}
>
Create Post
</button>
)
}