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
Section titled “Import”import { createMutation } from "@ic-reactor/react"Basic Usage
Section titled “Basic Usage”import { createMutation } from "@ic-reactor/react"import { backend } from "./reactor"
const createPostMutation = createMutation(backend, { functionName: "create_post", invalidateQueries: [{ functionName: "get_posts" }],})Configuration
Section titled “Configuration”| 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) |
Handling Canister Errors
Section titled “Handling Canister Errors”The onCanisterError callback is designed to handle business logic errors returned by the canister (e.g., Result.Err), separate from network or system errors.
Why use onCanisterError?
Section titled “Why use onCanisterError?”- Specific Targeting:
onErrorcatches everything (network issues, timeouts, throw errors).onCanisterErroronly 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) },})Return Value
Section titled “Return Value”| Property | Type | Description |
|---|---|---|
useMutation |
(options?) => UseMutationResult |
React hook for components |
execute |
(args) => Promise<T> |
Imperative call that runs the factory-level callback chain |
Examples
Section titled “Examples”TanStack Router with Actions
Section titled “TanStack Router with Actions”Combine mutations with TanStack Router’s action handlers:
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> )}Optimistic Updates
Section titled “Optimistic Updates”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 componentfunction 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.
Form with React Hook Form
Section titled “Form with React Hook Form”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> )}Direct Execution (Outside React)
Section titled “Direct Execution (Outside React)”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 functionasync 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 onClickfunction 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.
Multiple Related Refetches
Section titled “Multiple Related Refetches”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.
Option 1: Fixed Queries (known up front)
Section titled “Option 1: Fixed Queries (known up front)”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 ],})Option 2: Per-Component Queries
Section titled “Option 2: Per-Component Queries”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 componentfunction CommentForm({ postId }: { postId: string }) { const { mutate } = createCommentMutation.useMutation({ // Invalidate this post and its comments after the mutation invalidateQueries: [getPost([postId]), getComments([postId])], })
// ...}Refetching with Query Objects
Section titled “Refetching with Query Objects”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],})Callback Chaining
Section titled “Callback Chaining”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 firstconst updateProfileMutation = createMutation(backend, { functionName: "update_profile", onSuccess: () => { analytics.track("profile_updated") },})
// Hook level - runs secondfunction 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.
With Loading State UI
Section titled “With Loading State UI”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> )} </> )}Best Practices
Section titled “Best Practices”1. Define Invalidate Queries
Section titled “1. Define Invalidate Queries”Always specify which queries should be invalidated:
import { createMutation } from "@ic-reactor/react"import { backend } from "./reactor"
// ✅ Good - explicitly invalidate related dataconst mutation = createMutation(backend, { functionName: "create_post", invalidateQueries: [ { functionName: "get_posts" }, { functionName: "get_posts_count" }, ],})
// ❌ Bad - keys start with the canister ID, so this matches nothingconst matchesNothing = createMutation(backend, { functionName: "create_post", invalidateQueries: [["get_posts"]],})
// ❌ Bad - no invalidation, cache becomes staleconst goesStale = createMutation(backend, { functionName: "create_post",})2. Handle Errors
Section titled “2. Handle Errors”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} />}3. Use Pending State
Section titled “3. Use Pending State”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> )}See Also
Section titled “See Also”- useActorMutation — Direct hook usage
- Mutations Guide — Mutation patterns
- createQuery — Query factory