CanisterError
Business logic errors from your canister (e.g., InsufficientFunds, NotFound, Unauthorized)
IC Reactor provides structured error handling for both canister-level and network-level errors. This guide covers everything you need to know about handling errors in your application.
IC Reactor distinguishes between three types of errors:
CanisterError
Business logic errors from your canister (e.g., InsufficientFunds, NotFound, Unauthorized)
CallError
Network, agent, or trap errors (e.g., connection timeout, canister trapped)
ValidationError
Argument validation failures raised by DisplayReactor before the call is
ever sent
Thrown when your canister returns an Err variant from a Result type:
import { CanisterError } from "@ic-reactor/react"
// Your canister method returns Result<User, GetUserError>// On Err, IC Reactor throws CanisterError with the error value
if (error instanceof CanisterError) { console.log("Canister error:", error.err) console.log("Error code:", error.code) // Extracted from variant key // error.err contains the Err variant, e.g.: // { NotFound: null } // { Unauthorized: { reason: "Not owner" } } // { InsufficientFunds: { balance: 100n, required: 500n } }}Thrown for network, agent, or canister trap errors:
import { CallError } from "@ic-reactor/react"
if (error instanceof CallError) { console.log("Call error:", error.message) // Network timeout, canister trapped, etc.}Thrown by a DisplayReactor when the arguments you pass fail its validators —
before anything is sent to the canister. It carries the failing method name and
one issue per invalid field:
import { isValidationError } from "@ic-reactor/react"
try { await transferMutation.execute([{ to: "", amount: "-100" }])} catch (error) { if (isValidationError(error)) { console.log(error.methodName) // "transfer" console.log(error.issues) // [ // { path: ["to"], message: "Recipient is required" }, // { path: ["amount"], message: "Amount must be positive" } // ] }}@ic-reactor/react also ships form helpers built on it —
mapValidationErrors, getFieldError, getFieldErrors,
extractValidationErrors, and handleValidationError. See
Validation for the full surface.
import { CanisterError, CallError } from "@ic-reactor/react"import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) { const { data, error, isError, isPending, refetch } = useActorQuery({ functionName: "getUser", args: [userId], })
if (isPending) { return <LoadingSpinner /> }
if (isError) { if (error instanceof CanisterError) { // Handle specific business logic errors if ("NotFound" in error.err) { return <UserNotFound userId={userId} /> } if ("Unauthorized" in error.err) { return <Unauthorized message={error.err.Unauthorized.reason} /> } return ( <div> Error: {error.code} - {JSON.stringify(error.err)} </div> ) }
// Network or other errors return ( <div className="error"> <p>Failed to load user: {error.message}</p> <button onClick={() => refetch()}>Retry</button> </div> ) }
return <UserCard user={data} />}Use React Error Boundaries for unexpected errors. The react-error-boundary package provides a convenient ErrorBoundary component:
pnpm add react-error-boundaryimport { ErrorBoundary } from "react-error-boundary"
function ErrorFallback({ error, resetErrorBoundary }) { return ( <div className="error-fallback"> <h2>Something went wrong</h2> <pre>{error.message}</pre> <button onClick={resetErrorBoundary}>Try again</button> </div> )}
function App() { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <YourApp /> </ErrorBoundary> )}onCanisterError fires only for Result { Err } variants from the canister.
This keeps canister business-logic errors separate from network and agent failures:
function TransferForm() { const { mutate, isPending } = useActorMutation({ functionName: "transfer", // Only fires for canister Result { Err } variants onCanisterError: (error) => { if ("InsufficientFunds" in error.err) { const { balance, required } = error.err.InsufficientFunds toast.error(`Insufficient funds. Have: ${balance}, Need: ${required}`) } else if ("InvalidRecipient" in error.err) { toast.error("Invalid recipient address") } else { toast.error("Transaction failed: " + error.code) } }, // Fires for ALL errors — use for logging/monitoring onError: (error) => { console.error("Transfer error", error) }, })
// ...}onError fires for every error type. Use it when you want a single handler or
when you need to distinguish manually:
import { CanisterError } from "@ic-reactor/react"
function TransferForm() { const { mutate, isPending, error } = useActorMutation({ functionName: "transfer", onError: (error) => { if (error instanceof CanisterError) { // Handle specific error variants if ("InsufficientFunds" in error.err) { const { balance, required } = error.err.InsufficientFunds toast.error(`Insufficient funds. Have: ${balance}, Need: ${required}`) return } if ("InvalidRecipient" in error.err) { toast.error("Invalid recipient address") return } }
// Generic error handling toast.error("Transfer failed: " + error.message) }, })
// ...}const { mutateAsync } = useActorMutation({ functionName: "transfer",})
const handleTransfer = async () => { try { const result = await mutateAsync([recipient, amount]) toast.success("Transfer successful!") } catch (error) { if (error instanceof CanisterError) { handleCanisterError(error) } else { toast.error("Network error. Please try again.") } }}For type-safe error handling, define your error types:
// Define error type matching your canister's Candidtype TransferError = | { InsufficientFunds: { balance: bigint; required: bigint } } | { InvalidRecipient: null } | { AmountTooSmall: { minimum: bigint } } | { Unauthorized: null }
function handleTransferError(error: TransferError): string { if ("InsufficientFunds" in error) { return `Insufficient balance: ${error.InsufficientFunds.balance}` } if ("InvalidRecipient" in error) { return "Invalid recipient address" } if ("AmountTooSmall" in error) { return `Minimum amount: ${error.AmountTooSmall.minimum}` } if ("Unauthorized" in error) { return "You are not authorized to perform this action" } return "Unknown error"}
// Usageif (error instanceof CanisterError) { const message = handleTransferError(error.err as TransferError) toast.error(message)}Most canister-call failures cannot change on retry: a canister Err variant, a
ValidationError, a Candid encode failure that never produced a request, a
decode failure on the reply (the same reply decodes to the same error — and for
an update, the canister may already have committed the change, so a retry would
submit it again), a replica rejection the replica will simply repeat for an
identical call. Retrying those costs several attempts and seconds of backoff to
arrive at the same failure. Only a CallError caused by an agent-level fault —
a transport, protocol, or certificate failure, or a
SysTransient/SysUnknown rejection — can plausibly produce a different
result next time.
IC Reactor ships this classification as two functions, exported from
@ic-reactor/core and re-exported by @ic-reactor/react:
isRetryableReactorError(error) — returns true only when a retry could
help. CanisterError, ValidationError, and a CallError without an
agent error underneath (encode/decode/transform failures) are never
retryable. Within agent errors the bias runs the other way: an unrecognized
kind, or a rejection whose code cannot be read, still retries, so an
unfamiliar transport-level fault is never silently made fatal.reactorRetry(failureCount, error) — a TanStack Query retry predicate
built on it: up to 3 retries (4 attempts in total) for retryable errors, none
otherwise. It always returns false on the server
(typeof window === "undefined"), preserving TanStack Query’s
no-retries-on-server default so a failed prefetch or render keeps failing
fast.The QueryClient that defineReactor creates already uses reactorRetry as
its default for queries. A QueryClient you construct yourself opts in
explicitly:
import { QueryClient } from "@tanstack/react-query"import { reactorRetry } from "@ic-reactor/react"
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: reactorRetry, }, },})Combine isRetryableReactorError with your own policy when you need different
attempt counts or delays:
import { QueryClient } from "@tanstack/react-query"import { isRetryableReactorError } from "@ic-reactor/react"
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => failureCount < 5 && isRetryableReactorError(error), retryDelay: (attemptIndex) => { // Exponential backoff: 1s, 2s, 4s... return Math.min(1000 * 2 ** attemptIndex, 30000) }, }, mutations: { retry: false, // Never retry mutations by default }, },})Per-query overrides still work as in plain TanStack Query:
const { data } = useActorQuery({ functionName: "getPrice", args: [], retry: 5, // Retry up to 5 times retryDelay: 1000, // Wait 1 second between retries})function DataView() { const { data, error, refetch, isError, isFetching } = useActorQuery({ functionName: "getData", args: [], })
if (isError) { return ( <div className="error-state"> <p>Failed to load data</p> <button onClick={() => refetch()} disabled={isFetching}> {isFetching ? "Retrying..." : "Retry"} </button> </div> ) }
return <DataDisplay data={data} />}function CreateForm() { const { mutate, error, isError, reset } = useActorMutation({ functionName: "create", })
return ( <form> {isError && ( <div className="error-banner"> <p>{error.message}</p> <button type="button" onClick={reset}> Dismiss </button> </div> )} {/* form fields */} </form> )}Provide fallback data when queries fail:
const { data } = useActorQuery({ functionName: "getConfig", args: [], // Show default config if query fails placeholderData: { theme: "light", language: "en", },})import { isRetryableReactorError, reactorRetry } from "@ic-reactor/react"
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { // Log all errors for debugging console.error("Query failed:", { failureCount, error, isRetryable: isRetryableReactorError(error), })
return reactorRetry(failureCount, error) }, }, },})import * as Sentry from "@sentry/react"
const { mutate } = useActorMutation({ functionName: "transfer", onError: (error) => { // Report to error tracking Sentry.captureException(error, { tags: { type: error instanceof CanisterError ? "canister" : "network", }, }) },})onCanisterError for mutations — Separates canister Result { Err } variants from network/agent errors without manual instanceof checksCanisterError, CallError, and ValidationError differentlyreactorRetry (the default when defineReactor creates the QueryClient) classifies this for you// Helper for checking specific error variantsfunction isInsufficientFunds(error: unknown): error is CanisterError & { err: { InsufficientFunds: { balance: bigint } }} { return ( error instanceof CanisterError && "InsufficientFunds" in (error.err as object) )}
// Usageif (isInsufficientFunds(error)) { const balance = error.err.InsufficientFunds.balance // TypeScript knows the type is correct}