Automatic Caching
Built-in TanStack Query integration for automatic caching, deduplication, and background updates
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:
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 sourceconst backend = new Reactor<_SERVICE>({ clientManager, idlFactory, canisterId,})
// Data flows reactively: cache → components → automatic updatesconst { 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
| 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/reactimport { Reactor } from "@ic-reactor/react"
// Non-React usersimport { 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",})| Option | Type | Required | Description |
|---|---|---|---|
clientManager | ClientManager | Yes | The ClientManager instance |
idlFactory | IDL.InterfaceFactory | Yes | Candid IDL factory from declarations |
canisterId | string | Principal | Yes | The canister ID to connect to |
name | string | No | Human-readable name for debugging |
pollingOptions | PollingOptions | No | Custom polling options for updates |
| 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"],})// Returns: [canisterId, "getUser", "user-123"]Use this for:
invalidateQueries in mutationsGet TanStack Query options for a method call:
const queryOptions = backend.getQueryOptions({ functionName: "getUser", args: ["user-123"],})
// Use in loadersconst loader = async () => { await queryClient.prefetchQuery(queryOptions) return null}
// Or with useQuery directlyconst 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 canisterawait 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.
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 methodconst user = await backend.callMethod({ functionName: "getUser", args: ["user-123"],})
// Update methodconst result = await backend.callMethod({ functionName: "createPost", args: [{ title: "Hello", content: "World" }],})// React Router loaderexport 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 reactorsexport 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 }):
Ok: Returns the success value directlyErr: Throws a CanisterError containing the error variantimport { 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 typesreactor.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.