Skip to content

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,
canisterId,
})
// 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 authentication state across all reactors via ClientManager

FeatureStandard ActorReactor
Type-safe method calls
Query caching✅ Built-in
Automatic refetching✅ Background updates
Result unwrapping❌ Manual✅ Automatic
Error typing❌ GenericCanisterError<E>
Identity sharing❌ Per-actor✅ Via ClientManager
Query invalidationinvalidateQueries()
// 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,
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai",
name: "backend",
})
OptionTypeRequiredDescription
clientManagerClientManagerYesThe ClientManager instance
idlFactoryIDL.InterfaceFactoryYesCandid IDL factory from declarations
canisterIdstring | PrincipalYesThe canister ID to connect to
namestringNoHuman-readable name for debugging
pollingOptionsPollingOptionsNoCustom polling options for updates
PropertyTypeDescription
canisterIdPrincipalThe canister’s Principal
namestringName of the reactor
serviceIDL.ServiceClassThe Candid service interface
agentHttpAgentThe IC HTTP agent (getter)
queryClientQueryClientTanStack Query client (getter)
clientManagerClientManagerThe shared client manager

Call a canister method directly:

const result = await backend.callMethod({
functionName: "getUser",
args: ["user-123"],
})
OptionTypeDescription
functionNamestringName of the canister method
argsarrayArguments to pass
callConfigCallConfigOptional 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"],
})
// Returns: [canisterId, "getUser", "user-123"]

Use this for:

  • invalidateQueries in mutations
  • Manual cache operations
  • Query invalidation

Get TanStack Query options for a method call:

const queryOptions = backend.getQueryOptions({
functionName: "getUser",
args: ["user-123"],
})
// 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()

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.

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,
withProcessEnv: true,
})
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",
})

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

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

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"],
})
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) {
if (!this.clientManager.isAuthenticated) {
console.warn("User not authenticated")
}
return super.fetchQuery(params)
}
}
const backend = new LoggingReactor({
clientManager,
idlFactory,
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: "alice@example.com" }],
})
// 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,
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.