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: "createPost", invalidateQueries: [backend.generateQueryKey({ functionName: "getPosts" })],})Configuration
Section titled “Configuration”| 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) |
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.
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: "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> )}Optimistic Updates
Section titled “Optimistic Updates”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 componentconst { 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(), }) },})Form with React Hook Form
Section titled “Form with React Hook Form”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> )}Direct Execution (Outside React)
Section titled “Direct Execution (Outside React)”Execute mutations from utilities or event handlers:
const withdrawMutation = createMutation(backend, { functionName: "withdraw",})
// In an event handler or utility functionasync 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.
Multiple Related Refetches
Section titled “Multiple Related Refetches”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" }), ],})Option 2: Dynamic Keys (Recommended)
Section titled “Option 2: Dynamic Keys (Recommended)”To keep your code clean and reusable, define helper functions for your query keys:
// Define reusable query key helpersconst 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 componentfunction CommentForm({ postId }) { const queryClient = useQueryClient()
const { mutate } = createCommentMutation.useMutation({ // Invalidate the post and comments after mutation invalidateQueries: [getPostKey(postId), getCommentsKey(postId)], })
// ...}Refetching with Query Factories
Section titled “Refetching with Query Factories”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(), ],})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:
// Factory level - runs firstconst updateProfileMutation = createMutation(backend, { functionName: "updateProfile", onSuccess: () => { analytics.track("profile_updated") },})
// Hook level - runs secondconst { 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().
With Loading State UI
Section titled “With Loading State UI”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> )} </> )}Best Practices
Section titled “Best Practices”1. Define Invalidate Queries
Section titled “1. Define Invalidate Queries”Always specify which queries should be invalidated:
// ✅ Good - explicitly invalidate related dataconst mutation = createMutation(backend, { functionName: "createPost", invalidateQueries: [ backend.generateQueryKey({ functionName: "getPosts" }), backend.generateQueryKey({ functionName: "getPostCount" }), ],})
// ❌ Bad - no invalidation, cache becomes staleconst mutation = createMutation(backend, { functionName: "createPost",})2. Handle Errors
Section titled “2. Handle Errors”Always provide user feedback on errors:
const { mutate, error } = mutation.useMutation({ onError: (error) => toast.error(error.message),})
{error && <ErrorMessage error={error} />}3. Use Pending State
Section titled “3. Use Pending State”Disable buttons and show loading indicators:
<button disabled={isPending}> {isPending ? <Spinner /> : "Submit"}</button>See Also
Section titled “See Also”- useActorMutation — Direct hook usage
- Mutations Guide — Mutation patterns
- createQuery — Query factory