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.
import { Reactor, useReactorQuery } from "@ic-reactor/react"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
// A Reactor isn't just a connection — it's a reactive data sourceconst 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 updatesfunction UserName({ userId }: { userId: string }) { const { data, isLoading, refetch } = useReactorQuery({ reactor: backend, functionName: "getUser", args: [userId], })
return ( <button onClick={() => refetch()}>{isLoading ? "…" : data?.name}</button> )}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 from @ic-reactor/core:
import { Reactor } from "@ic-reactor/core"import { Reactor, ClientManager } from "@ic-reactor/react"import { QueryClient } from "@tanstack/react-query"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 |
name required?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:
ClientManager state.canisterId optional?The canisterId is optional because it can be automatically resolved using the name — on a host where the ic_env cookie is trusted.
ic_env cookie using the name.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, effectiveTarget: { canisterId: managementCanisterId }, })// Returns:// [otherCanisterId, "getUser", { effectiveTarget: { canisterId: managementCanisterId } }, '["user-123"]']The key is composed as:
[resolvedCanisterId, functionName, { transform }?, { agent }?, { effectiveTarget }?, argKey?, ...queryKey]resolvedCanisterId — callConfig.canisterId when supplied (normalized via Principal.from(...).toString()), otherwise the reactor’s own canister ID.{ transform } — present whenever the reactor’s transform is not "candid", so a Reactor key has none and a DisplayReactor key carries { transform: "display" }. The two never share a cache entry.{ agent } — { agent: n } when callConfig.agent is set and is not the ClientManager’s own agent, where n numbers that agent within the running process. A query through another agent is answered for that agent’s identity or network, so it never shares a cache entry with the same query through the manager’s agent. Each process counts from a random start, so an override entry in a dehydrated server cache, or in a cache persisted and restored later, is not found by another process’s agents.{ 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 with BigInt values rendered as decimal strings and the keys of every plain object sorted, so { b, a } and { a, b } give the same key. A bare JSON.stringify(args) does not match it once a record’s fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a Uint8Array, a number[] and a DisplayReactor’s hex text holding the same bytes give the same key. A DisplayReactor writes each opt and variant in one form, whichever it was given in, so an opt given bare or as [value], none given as null, undefined or [], and a variant given with or without _type give the same key. A vec record { text; T } given to it as an object or a Map is written as its entries in order, since the order is part of what it sends. A record or variant given to it as a class instance is written as the plain object its codec sends, a field its class supplies through a getter included. Fields a record does not declare are left out and every value of reserved is written as null, since neither is sent, and a DisplayReactor writes a float or an integer of 32 bits or fewer given as text as its number, and a Principal as its text. An argument the reactor refuses, such as undefined where Candid null is required or a bigint where a DisplayReactor takes text, is written behind a tag of its own, so the call fails instead of being answered from the cache entry of an argument it takes. Args 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 mutationsIf 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:
import { useQuery } from "@tanstack/react-query"
const queryOptions = backend.getQueryOptions({ functionName: "getUser", args: ["user-123"], callConfig: { canisterId: otherCanisterId },})
// Use in loadersconst loader = async () => { await queryClient.prefetchQuery(queryOptions) return null}
// Or with useQuery directly, in a component or a custom hookconst useUser = () => useQuery(queryOptions)Object containing queryKey and queryFn for TanStack Query. For an update
method it also holds a retry from getQueryRetry, which
retries only a SysTransient rejection; spread a retry of your own after
the options to replace it. A query method’s options hold no retry, so the
QueryClient’s default applies.
The retry a TanStack query of a method falls back to when the query sets
none. Every query path applies it: getQueryOptions, fetchQuery, and the
query hooks and factories of @ic-reactor/react.
const retry = backend.getQueryRetry( "createPost", backend.generateQueryKey({ functionName: "createPost", args: [{ title: "Hello", content: "World" }], }))For a query method it returns undefined, and the QueryClient’s retry
defaults apply. An update method runs on the canister again each time the
query function runs, as a new call the IC cannot tell from a retry, so for one
it returns a function that retries only a SysTransient rejection (see
isRetryableUpdateError), which proves the canister never ran the call. It
retries that only as often as the QueryClient’s retry default for the key
allows (setQueryDefaults, then defaultOptions.queries, then TanStack
Query’s own three retries in a browser): reactorUpdateRetry under
defineReactor’s reactorRetry, nothing under retry: false. See
Update Methods in Queries.
Invalidate all cached queries for this canister:
async function saveProfile() { // After a successful mutation, invalidate all queries for this canister and // wait for the ones in use to refetch await backend.callMethod({ functionName: "updateProfile", args: [{ name: "Alice" }], }) await backend.invalidateQueries()
// Invalidate a cached query that used a per-call canister override await backend.invalidateQueries( { functionName: "getUser", args: ["user-123"] }, { canisterId: otherCanisterId } )}This will mark all queries as stale and trigger a refetch for any active queries.
It returns TanStack Query’s Promise<void>, which resolves once the active
queries it matched have refetched. A refetch that fails does not reject it.
Await it when the next step should see the refetched data. An arrow that
returns it, such as onSuccess: () => backend.invalidateQueries(), keeps the
mutation pending until those refetches finish, as TanStack Query recommends.
With the no-floating-promises lint rule, a call you do not want to wait for
needs a void in front of it.
In a mutation, list the queries in its invalidateQueries option instead. The
same { functionName, args? } shape works there, resolved against the
mutation’s reactor: invalidateQueries: [{ functionName: "getUser" }]. See
Mutations.
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.
Get a reactor for another canister of the same interface, such as another ICRC
ledger. It is the same class (a DisplayReactor gives a DisplayReactor) on
the same ClientManager, so it shares the agent, identity and QueryClient,
and it starts with this reactor’s interface, name, polling options and, for a
DisplayReactor, validators. Only the canister differs.
import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { idlFactory, type _SERVICE as Ledger } from "./declarations/ledger"
const ledger = new DisplayReactor<Ledger>({ clientManager, idlFactory, name: "ledger", canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP})
const ckbtc = ledger.forCanister("mxzaz-hqaaa-aaaar-qaada-cai")
async function getSymbol() { return ckbtc.fetchQuery({ functionName: "icrc1_symbol" })}The same canister id always gives the same reactor, and every sibling shares that memo, so it is safe to call during render. Hooks, query objects and mutations built on a sibling key and call its canister, so several tokens can be on screen at once:
import { useMemo } from "react"import { createActorHooks } from "@ic-reactor/react"
function TokenSymbol({ canisterId }: { canisterId: string }) { const { useActorQuery } = useMemo( () => createActorHooks(ledger.forCanister(canisterId)), [canisterId] ) const { data } = useActorQuery({ functionName: "icrc1_symbol" }) return <span>{data}</span>}| Option | Type | Description |
|---|---|---|
canisterId |
string | Principal |
The other canister |
A reactor of the same type as the one it was called on.
Dynamically update the canister ID for this reactor. This is useful when one reactor instance should follow a single selection, and nothing else built on it needs the previous canister. For several canisters of the same type at once (e.g., multiple ICRC tokens), use forCanister instead.
import { Principal } from "@icp-sdk/core/principal"
// Switch to a different ledger canisterledgerReactor.setCanisterId("ryjl3-tyaaa-aaaaa-aaaba-cai")
// Or use a PrincipalledgerReactor.setCanisterId(Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai"))| Option | Type | Description |
|---|---|---|
canisterId |
string | Principal |
The new canister ID |
import { ClientManager, Reactor } from "@ic-reactor/react"import { QueryClient } from "@tanstack/react-query"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",})async function loadAndPost() { // 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" }], })
return { user, result }}// React Router loaderexport async function loader({ params,}: { params: Readonly<Record<string, string | undefined>>}) { const { userId } = params if (!userId) throw new Response("Not found", { status: 404 })
const queryOptions = backend.getQueryOptions({ functionName: "getUser", args: [userId], })
await queryClient.prefetchQuery(queryOptions)
return null}Write through the reactor’s own QueryClient, backend.queryClient. It is the
client the hooks read, and unlike useQueryClient() it needs no
QueryClientProvider and works outside React:
import type { User } from "./declarations/backend"
function updateUserCache(userId: string, updates: Partial<User>) { const queryKey = backend.generateQueryKey({ functionName: "getUser", args: [userId], })
// `old` is undefined when the user is not cached backend.queryClient.setQueryData<User>(queryKey, (old) => old ? { ...old, ...updates } : old )}A query object from createQuery or a query factory does the same with its
data type already known: getUser([userId]).setData(...), or
optimisticUpdate(...) with a rollback for a mutation.
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",})For a multi-token wallet or a canister explorer, give each canister its own
sibling with forCanister. This route loader asks the ledger
reactor for the sibling of the canister in the URL and prefetches through it,
so two tokens on screen never cross:
import { createFileRoute } from "@tanstack/react-router"import { ledgerReactor } from "@/canisters/ledger/reactor"
export const Route = createFileRoute("/wallet/$canisterId")({ component: TokenWallet, loader: async ({ params: { canisterId } }) => { // The same sibling reactor each time for the same canister const token = ledgerReactor.forCanister(canisterId)
// Prefetch token info, cached under this canister's own keys const [name, symbol] = await Promise.all([ token.fetchQuery({ functionName: "icrc1_name" }), token.fetchQuery({ functionName: "icrc1_symbol" }), ])
return { name, symbol } },})
function TokenWallet() { const { name, symbol } = Route.useLoaderData()
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 is cache-first, like TanStack Query’s ensureQueryData. When the
cache holds a value for the call, that value is returned without a network
request, even if it is stale or was invalidated. Only when there is no cached
value does it call the canister and cache the result. A staleTime passed in
the options has no effect.
To get a value the canister returns now, call callMethod (no cache), or use
the query client’s own fetchQuery, which refetches when the entry is stale.
Wrap it in clientManager.fetchAcrossIdentitySwitch: when a sign-in or
sign-out cancels a refetch of an entry that held data, TanStack resolves it with
that data, the previous principal’s, and the wrapper runs it again instead:
const fresh = await backend.clientManager.fetchAcrossIdentitySwitch(() => backend.queryClient.fetchQuery({ ...backend.getQueryOptions({ functionName: "getUser", args: ["user-123"] }), staleTime: 0, }))An optional second argument passes further TanStack Query options for the
fetch, such as retry, networkMode or meta. The query key and function
always come from the first argument. A query factory’s fetch() passes its
config’s networkMode, retry, retryDelay and meta this way. Without a
retry there, an update method retries only a SysTransient rejection; see
getQueryRetry.
const result = await backend.fetchQuery( { functionName: "getUser", args: ["user-123"] }, { retry: 2 })A sign-in or sign-out while the fetch is in flight cancels it, so the previous
identity’s answer is never cached (see
ClientManager). The
fetch then runs again for the identity installed now and resolves with that
answer, rather than rejecting with TanStack’s CancelledError. A route loader
that awaits it during a sign-out gets the signed-out answer, not its error
boundary. The query factories’ fetch() does the same.
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.
import { Reactor, type FunctionName, type ReactorCallParams, type ReactorQueryData, type ReactorReturnErr, type ReactorReturnOk,} from "@ic-reactor/react"import type { FetchQueryOptions } from "@tanstack/react-query"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE as BackendService,} from "./declarations/backend"
// The options `fetchQuery` takes after its paramstype FetchOptions<M extends FunctionName<BackendService>> = Omit< FetchQueryOptions< ReactorQueryData<ReactorReturnOk<BackendService, M>>, ReactorReturnErr<BackendService, M> >, "queryKey" | "queryFn">
class LoggingReactor extends Reactor<BackendService> { // Override callMethod to add logging async callMethod<M extends FunctionName<BackendService>>( params: Omit<ReactorCallParams<BackendService, M>, "queryKey"> ): Promise<ReactorReturnOk<BackendService, M>> { 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<M extends FunctionName<BackendService>>( params: ReactorCallParams<BackendService, M>, options?: FetchOptions<M> ) { const principal = await this.clientManager.getUserPrincipal() if (principal.isAnonymous()) { console.warn("User not authenticated") } return super.fetchQuery(params, options) }}
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 }):
Ok: Returns the success value directlyErr: Throws a CanisterError containing the error variantimport { CanisterError } from "@ic-reactor/react"
// Canister returns: Result<User, CreateUserError>async function createUser() { try { const user = await backend.callMethod({ functionName: "createUser", }) // 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"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
const reactor = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", 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.