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.
Basic Mutation
Section titled “Basic Mutation”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:
isIdleorstatus === 'idle'- The mutation is currently idle or in a fresh/reset stateisPendingorstatus === 'pending'- The mutation is currently runningisErrororstatus === 'error'- The mutation encountered an errorisSuccessorstatus === '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 anisErrorstate, the error is available via theerrorproperty.data- If the mutation is in anisSuccessstate, the return data is available via thedataproperty.
Mutation Arguments
Section titled “Mutation Arguments”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> )}Side Effects
Section titled “Side Effects”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!") }, })}Per-Mutate Callbacks
Section titled “Per-Mutate Callbacks”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> )}Invalidating Queries After Mutation
Section titled “Invalidating Queries After Mutation”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 (itscallConfig.canisterId, if set); - a query object from
createQuery,createSuspenseQuery,createInfiniteQueryorcreateSuspenseInfiniteQuery; - a query factory from
createQueryFactoryand the othercreate*Factoryfunctions, which covers every query it returns, whatever its args; - a query key, from
generateQueryKeyorgetQueryKey().
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}`) }, })}Form Handling
Section titled “Form Handling”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> )}Async/Await with mutateAsync
Section titled “Async/Await with mutateAsync”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> )}Resetting Mutation State
Section titled “Resetting Mutation State”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> )}Optimistic Updates
Section titled “Optimistic Updates”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 reactorconst 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.
Mutation Factory
Section titled “Mutation Factory”For reusable mutation configurations, use createMutation:
import { createMutation } from "@ic-reactor/react"import { backend } from "../reactor"import type { PostInput } from "../declarations/backend"
// Define mutation onceconst createPostMutation = createMutation(backend, { functionName: "create_post", invalidateQueries: [{ functionName: "get_posts" }],})
// Use in componentsfunction 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.
Error Handling
Section titled “Error Handling”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 unitsconst 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.") } }, })}Retries
Section titled “Retries”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.
Type Safety
Section titled “Type Safety”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> )}Further Reading
Section titled “Further Reading”- Query Caching - Cache invalidation after mutations
- Error Handling - Detailed error handling patterns
- Type Safety - Understanding typed mutations