Skip to content
IC Reactor

Reactor

Reactor is the core class that represents a reactive connection to an Internet Computer canister. The name “Reactor” reflects its fundamental purpose: it reacts to changes and propagates updates throughout your application.

Traditional IC development treats canister calls as one-shot operations—you call, you get data, you’re done. But modern applications need more:

  • Data should stay fresh — automatically refetch stale data in the background
  • Requests should be deduplicated — multiple components fetching the same data shouldn’t make multiple calls
  • Cache should be managed — know when to invalidate and refetch
  • Errors should be typed — canister errors are domain-specific, not just strings

Reactor wraps a canister connection with TanStack Query integration, transforming static canister calls into reactive data streams that your UI components can subscribe to.

// A Reactor isn't just a connection — it's a reactive data source
const backend = new Reactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend", // Required: Used for logging and environment lookup
canisterId, // Optional: omitted only where the ic_env cookie is trusted
})
// Data flows reactively: cache → components → automatic updates
const { data, isLoading, refetch } = useReactorQuery({
reactor: backend,
functionName: "getUser",
args: [userId],
})

If you’re familiar with the standard Actor class from @icp-sdk/core/agent, you might wonder why use Reactor. Here’s why:

Automatic Caching

Built-in TanStack Query integration for automatic caching, deduplication, and background updates

Result Unwrapping

Automatically unwraps Result<Ok, Err> types - no more manual Ok/Err checking

Type Transformations

With DisplayReactor, auto-convert BigInt → string, Principal → text for easy UI rendering

Identity Management

Shares one agent and identity across all reactors via ClientManager

Feature Standard Actor Reactor
Type-safe method calls
Query caching ✅ Built-in
Automatic refetching ✅ Background updates
Result unwrapping ❌ Manual ✅ Automatic
Error typing ❌ Generic CanisterError<E>
Identity sharing ❌ Per-actor ✅ Via ClientManager
Query invalidation invalidateQueries()
// React users - import from @ic-reactor/react
import { Reactor } from "@ic-reactor/react"
// Non-React users
import { Reactor } from "@ic-reactor/core"
import { Reactor, ClientManager } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/query-core"
import { idlFactory, type _SERVICE } from "./declarations/backend"
const queryClient = new QueryClient()
const clientManager = new ClientManager({ queryClient })
const backend = new Reactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend", // Required: Used for logging and environment lookup
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai", // Optional: omitted only where the ic_env cookie is trusted
})
Option Type Required Description
clientManager ClientManager Yes The ClientManager instance
idlFactory IDL.InterfaceFactory Yes Candid IDL factory from declarations
name string Yes Unique name for state management
canisterId string | Principal No The canister ID to connect to
pollingOptions PollingOptions No Custom polling options for updates

The name parameter is required for environment-based resolution. When canisterId is omitted, the Reactor uses this name to look up the canister ID in the ic_env cookie (PUBLIC_CANISTER_ID:<name>) — but only where that cookie is trusted; see below.

For more details, see the Canister Environment documentation.

It also serves as a unique identifier for:

  1. Debuggability: Providing meaningful labels in logs.
  2. Development: identifying the actor in the ClientManager state.

The canisterId is optional because it can be automatically resolved using the name — on a host where the ic_env cookie is trusted.

  • Automatic Resolution: If omitted, the Reactor looks the ID up in the ic_env cookie using the name.
  • Dynamic Usage: useful when the canister ID is not known at build time or varies by environment.
  • Only where the cookie is trusted: a local replica, or a host you opted in with allowEnvConfig. Cookies are not origin-isolated, so on any other host — a custom domain or mainnet — the cookie is not read and the constructor throws instead, naming the option to set. Bake the ID in at build time for those, which is what the codegen canisterId option does. A substituted canister ID cannot be caught by certificate verification: the attacker names a real canister, and its responses verify against the real root key.
Property Type Description
canisterId Principal The canister’s Principal
name string Name of the reactor
service IDL.ServiceClass The Candid service interface
agent HttpAgent The IC HTTP agent (getter)
queryClient QueryClient TanStack Query client (getter)
clientManager ClientManager The shared client manager

Call a canister method directly:

const result = await backend.callMethod({
functionName: "getUser",
args: ["user-123"],
})
Option Type Description
functionName string Name of the canister method
args array Arguments to pass
callConfig CallConfig Optional call configuration

Promise<ReturnType> — The method’s return value (with Result unwrapping)


Generate a TanStack Query cache key for a method call:

const queryKey = backend.generateQueryKey({
functionName: "getUser",
args: ["user-123"],
}, {
canisterId: otherCanisterId,
effectiveCanisterId: managementCanisterId,
})
// Returns:
// [otherCanisterId, "getUser", { effectiveTarget: { canisterId: managementCanisterId } }, '["user-123"]']

The key is composed as:

[resolvedCanisterId, functionName, { effectiveTarget }?, argKey?, ...queryKey]
  • resolvedCanisterIdcallConfig.canisterId when supplied (normalized via Principal.from(...).toString()), otherwise the reactor’s own canister ID.
  • { effectiveTarget } — a wrapper object holding either { canisterId } or { subnetId }, built from callConfig.effectiveTarget or from callConfig.effectiveCanisterId. A canisterId-shaped target is omitted when it equals resolvedCanisterId, so the common case produces no such segment.
  • argKey — args are one single string segment, JSON.stringify(args) with BigInt values rendered as decimal strings. They are not spread into separate elements, and args: [] still produces the literal "[]".
  • ...queryKey — a custom queryKey is spread onto the end; it never replaces the identity prefix.

Use this for:

  • invalidateQueries in mutations
  • Manual cache operations
  • Query invalidation

If a query uses callConfig, pass the same callConfig here so the generated key matches the cache entry used by fetchQuery, getQueryOptions, and the React query wrappers.


Get TanStack Query options for a method call:

const queryOptions = backend.getQueryOptions({
functionName: "getUser",
args: ["user-123"],
callConfig: { canisterId: otherCanisterId },
})
// Use in loaders
const loader = async () => {
await queryClient.prefetchQuery(queryOptions)
return null
}
// Or with useQuery directly
const result = useQuery(queryOptions)

Object containing queryKey and queryFn for TanStack Query.


Invalidate all cached queries for this canister:

// After a successful mutation, invalidate all queries for this canister
await backend.callMethod({ functionName: "updateProfile", args: [newProfile] })
backend.invalidateQueries()
// Invalidate a cached query that used a per-call canister override
backend.invalidateQueries(
{ functionName: "getUser", args: ["user-123"] },
{ canisterId: otherCanisterId }
)

This will mark all queries as stale and trigger a refetch for any active queries.


Get the Candid service interface (IDL.ServiceClass):

const service = backend.getServiceInterface()
console.log(service._fields) // Array of [methodName, FuncClass]

Useful for introspection and codec generation.


Dynamically update the canister ID for this reactor. This is useful when you want to reuse a reactor instance to interact with different canisters of the same type (e.g., multiple ICRC tokens).

// Switch to a different ledger canister
ledgerReactor.setCanisterId("ryjl3-tyaaa-aaaaa-aaaba-cai")
// Or use a Principal
ledgerReactor.setCanisterId(Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai"))
Option Type Description
canisterId string | Principal The new canister ID

src/reactor/index.ts
import { ClientManager, Reactor } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/query-core"
import { idlFactory, type _SERVICE } from "../declarations/backend"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 5 * 60_000,
},
},
})
export const clientManager = new ClientManager({
queryClient,
})
export const backend = new Reactor<_SERVICE>({
clientManager,
idlFactory,
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
name: "backend",
})
// Query method
const user = await backend.callMethod({
functionName: "getUser",
args: ["user-123"],
})
// Update method
const result = await backend.callMethod({
functionName: "createPost",
args: [{ title: "Hello", content: "World" }],
})
// React Router loader
export async function loader({ params }) {
const queryOptions = backend.getQueryOptions({
functionName: "getUser",
args: [params.userId],
})
await queryClient.prefetchQuery(queryOptions)
return null
}
import { useQueryClient } from "@tanstack/react-query"
function useUpdateUserCache() {
const queryClient = useQueryClient()
const updateUser = (userId: string, updates: Partial<User>) => {
const queryKey = backend.generateQueryKey({
functionName: "getUser",
args: [userId],
})
queryClient.setQueryData(queryKey, (old: User) => ({
...old,
...updates,
}))
}
return updateUser
}
import { Reactor, ClientManager } from "@ic-reactor/react"
import {
idlFactory as backendIdl,
type _SERVICE as BackendService,
} from "../declarations/backend"
import {
idlFactory as ledgerIdl,
type _SERVICE as LedgerService,
} from "../declarations/ledger"
// One ClientManager, shared across all reactors
export const backend = new Reactor<BackendService>({
clientManager,
idlFactory: backendIdl,
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
name: "backend",
})
export const ledger = new Reactor<LedgerService>({
clientManager,
idlFactory: ledgerIdl,
canisterId: import.meta.env.VITE_LEDGER_CANISTER_ID,
name: "ledger",
})

Use setCanisterId to reuse a single reactor instance for multiple canisters of the same type. This is useful for multi-token wallets or canister explorers:

import { createFileRoute } from "@tanstack/react-router"
import { ledgerReactor } from "@/canisters/ledger/reactor"
import { icrc1NameQuery, icrc1SymbolQuery } from "@/canisters/ledger/hooks"
export const Route = createFileRoute("/wallet/$canisterId")({
component: TokenWallet,
loader: async ({ params: { canisterId } }) => {
// Switch reactor to target the canister from URL
ledgerReactor.setCanisterId(canisterId)
// Prefetch token info
await Promise.all([icrc1NameQuery.fetch(), icrc1SymbolQuery.fetch()])
return {}
},
})
function TokenWallet() {
// Queries automatically use the canister set by setCanisterId
const { data: name } = icrc1NameQuery.useQuery()
const { data: symbol } = icrc1SymbolQuery.useQuery()
return (
<div>
<h1>
{name} ({symbol})
</h1>
</div>
)
}

Fetch data from the canister and cache it using React Query:

const result = await backend.fetchQuery({
functionName: "getUser",
args: ["user-123"],
callConfig: { canisterId: otherCanisterId },
})

This method ensures the data is in the cache (fetching it if necessary) and returns it. It respects the standard React Query caching behavior.


Get the current data from the cache without fetching:

const user = backend.getQueryData({
functionName: "getUser",
args: ["user-123"],
}, { canisterId: otherCanisterId })
if (user) {
console.log("User in cache:", user.name)
}

You can extend the Reactor class to add custom functionality, logging, or middleware. The callMethod is now a class method, allowing you to override it easily.

class LoggingReactor extends Reactor<BackendService> {
// Override callMethod to add logging
async callMethod(params) {
console.log("Calling method:", params.functionName)
const result = await super.callMethod(params)
console.log("Method result:", result)
return result
}
// Override fetchQuery to add custom logic (e.g. auth checks)
async fetchQuery(params) {
const principal = await this.clientManager.getUserPrincipal()
if (principal.isAnonymous()) {
console.warn("User not authenticated")
}
return super.fetchQuery(params)
}
}
const backend = new LoggingReactor({
clientManager,
idlFactory,
name: "backend",
canisterId,
})

By extending Reactor, all methods relying on it (including callMethod, fetchQuery, and even createQuery consumers) will automatically use your custom logic.

IC Reactor automatically unwraps Candid Result types (variant { Ok: T; Err: E }):

  • On Ok: Returns the success value directly
  • On Err: Throws a CanisterError containing the error variant
import { CanisterError } from "@ic-reactor/react"
// Canister returns: Result<User, CreateUserError>
try {
const user = await backend.callMethod({
functionName: "createUser",
args: [{ name: "Alice", email: "[email protected]" }],
})
// user is User directly (not { Ok: User })
console.log("Created:", user.name)
} catch (error) {
if (error instanceof CanisterError) {
// error.err is the CreateUserError variant
console.log("Error code:", error.code)
if ("EmailAlreadyExists" in error.err) {
console.log("Email already taken")
}
}
}

This eliminates the need to manually check for Ok/Err variants in your code.

For argument validation before canister calls, use DisplayReactor which includes built-in validation support. Validators receive display types (strings for Principal/bigint), making them ideal for form validation.

import { DisplayReactor } from "@ic-reactor/core"
const reactor = new DisplayReactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend",
canisterId,
})
// Register validators that receive display types
reactor.registerValidator("transfer", ([input]) => {
const issues = []
if (!input.to) {
issues.push({ path: ["to"], message: "Recipient is required" })
}
if (!/^\d+$/.test(input.amount)) {
issues.push({ path: ["amount"], message: "Must be a valid number" })
}
return issues.length > 0 ? { success: false, issues } : { success: true }
})

See DisplayReactor for full validation documentation.