Skip to content
IC Reactor

CandidDisplayReactor

CandidDisplayReactor combines the dynamic Candid capabilities of CandidReactor with the automatic display transformations of DisplayReactor. It provides the best of both worlds: runtime Candid parsing and human-friendly type conversions.

This class is ideal for building UIs that interact with dynamic canisters:

  • Display transformations: bigint → string, Principal → string, optionals unwrapped
  • Dynamic Candid parsing: Initialize from Candid source or fetch from network
  • Optional validation: Register validators for form inputs
  • All standard Reactor methods: callMethod(), fetchQuery(), invalidateQueries(), setCanisterId()
import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "my-dynamic-actor",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.initialize() // Fetch IDL from network
// Results are already display-friendly (strings, not bigint)!
const balance = await reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner: "aaaaa-aa" }], // Principal as string!
})
console.log(balance) // "1000000" (string, not bigint)
import { CandidDisplayReactor } from "@ic-reactor/candid"
new CandidDisplayReactor<A = BaseActor, T extends TransformKey = "display">(
config: CandidDisplayReactorParameters
)
Parameter Type Required Description
clientManager ClientManager Yes Client manager from @ic-reactor/core
name string Yes Required for logging & environment lookup
canisterId CanisterId No Resolved from the ic_env cookie when omitted, on a trusted host only
candid string No Candid service definition (avoids network fetch)
idlFactory InterfaceFactory No IDL factory (if already available)
adapter CandidAdapter No Reuse an existing CandidAdapter instance
funcClass { methodName: string; func: IDL.FuncClass } No Register a single IDL.FuncClass (no initialize())
pollingOptions PollingOptions No Update-call polling configuration
validators Partial<Record<FunctionName<A>, DisplayValidator<A, M>>> No Initial validators for method arguments
import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "dynamic-actor",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
// Fetches Candid from canister metadata
await reactor.initialize()
// Now call with display types
const balance = await reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner: "aaaaa-aa" }], // string, not Principal!
})
// balance is "1000000" (string, not bigint)

The Candid usually arrives at run time, so a CandidDisplayReactor is usually created without a service type. A then defaults to BaseActor, and every method’s display-side arguments and results are typed unknown: nothing about their shape is known when the code compiles. Validators receive unknown args, and a callMethod() result has to be narrowed before it is rendered or passed on. (An untyped CandidReactor types the same values as any instead, because the raw side passes BaseActor’s any through.) There are two ways to work with these values:

  • Cast to the display shape you expect. For example const [input] = args as [{ to: string; amount: string }] in a validator, or (await reactor.callMethod(...)) as string for a nat result. The cast is not checked against the Candid, so keep it next to the code that relies on it.
  • Pass a service type when the interface is known at build time. Write it in Candid types, as @icp-sdk/bindgen generates it; the reactor derives the display types from it:
import { CandidDisplayReactor } from "@ic-reactor/candid"
import type { ActorMethod } from "@icp-sdk/core/agent"
import type { Principal } from "@icp-sdk/core/principal"
interface TokenService {
icrc1_balance_of: ActorMethod<[{ owner: Principal }], bigint>
transfer: ActorMethod<
[{ to: Principal; amount: bigint }],
{ Ok: bigint } | { Err: string }
>
}
const reactor = new CandidDisplayReactor<TokenService>({
name: "token",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
candid: `service : {
icrc1_balance_of : (record { owner : principal }) -> (nat) query;
transfer : (record { to : principal; amount : nat }) -> (variant { Ok : nat; Err : text });
}`,
})
await reactor.initialize()
// input is { to: string; amount: string }: no cast
reactor.registerValidator("transfer", ([input]) =>
input.to
? { success: true }
: { success: false, issues: [{ path: ["to"], message: "Required" }] }
)
// balance is string
const balance = await reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner: "aaaaa-aa" }],
})

The type parameter only names types. The methods the reactor can call still come from the Candid it parses or fetches, so keep the two in step.


CandidDisplayReactor automatically converts Candid types to display-friendly formats:

Candid Type Display Type Example
nat, int string 1000000n → "1000000"
nat64, int64 string 1234567890n → "1234567890"
principal string Principal.fromText(...) → "aaaaa-aa"
opt T T | undefined [value] → value, [] → undefined
blob string Hex-encoded string (no 0x prefix)

Fetch or parse Candid and update the service definition with display codecs.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.initialize()

If candid was provided in the constructor, it parses that. Otherwise, fetches from the network.


Register a single method by its Candid signature, with display codec generation.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.registerMethod({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
})
// Now use with display types!
const balance = await reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner: "aaaaa-aa" }], // string input
})
// balance is string (not bigint)

Register multiple methods at once.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.registerMethods([
{ functionName: "icrc1_name", candid: "() -> (text) query" },
{ functionName: "icrc1_symbol", candid: "() -> (text) query" },
{
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
},
])

Validation (Inherited from DisplayReactor)

Section titled “Validation (Inherited from DisplayReactor)”

Add form validation that works with display types. A reactor created without a service type passes its validators unknown args, so name the display shape you expect (or pass a service type; see Types: with and without a service type):

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
// Register a validator
reactor.registerValidator("transfer", (args) => {
const [input] = args as [{ to: string; amount: string }]
const issues = []
// input.to is string (not Principal)
if (!input.to) {
issues.push({ path: ["to"], message: "Recipient is required" })
}
// input.amount is string (not bigint)
if (!/^\d+$/.test(input.amount)) {
issues.push({ path: ["amount"], message: "Must be a valid number" })
}
return issues.length > 0 ? { success: false, issues } : { success: true }
})
// Validation runs automatically on callMethod()
try {
await reactor.callMethod({
functionName: "transfer",
args: [{ to: "", amount: "abc" }],
})
} catch (err) {
// ValidationError with issues
}
Method Description
registerValidator(name, fn) Register a validator for a method
unregisterValidator(name) Remove a validator
hasValidator(name) Check if a validator exists
validate(name, args) Validate without calling the canister
callMethodWithValidation() Call with async validator support

For quick calls without explicit registration:

Perform a dynamic query with display transformations.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
const balance = await reactor.queryDynamic<string>({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
args: [{ owner: "aaaaa-aa" }], // Display types!
})
// balance is "1000000" (string)

Perform a dynamic update call with display transformations.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
const result = await reactor.callDynamic({
functionName: "transfer",
candid:
"(record { to : principal; amount : nat }) -> (variant { Ok : nat; Err : text })",
args: [{ to: "aaaaa-aa", amount: "100" }], // Strings!
})

Dynamic query with TanStack Query caching.

import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
const balance = await reactor.fetchQueryDynamic({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
args: [{ owner: "aaaaa-aa" }],
})
// Subsequent calls return cached result

import { CandidDisplayReactor } from "@ic-reactor/candid"
import { formatTokenAmount } from "@ic-reactor/core"
async function getTokenBalance(canisterId: string, owner: string) {
const reactor = new CandidDisplayReactor({
name: "token-checker",
canisterId,
clientManager,
})
await reactor.registerMethod({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
})
await reactor.registerMethod({
functionName: "icrc1_decimals",
candid: "() -> (nat8) query",
})
// Pass Principal as string, get balance as string. The untyped reactor
// returns `unknown`; a nat displays as a string, a nat8 as a number.
const [balance, decimals] = (await Promise.all([
reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner }], // owner is a string like "aaaaa-aa"
}),
reactor.callMethod({ functionName: "icrc1_decimals" }),
])) as [string, number]
// The balance is in base units ("150000000"): format it with the token's
// decimals, never `Number(balance) / 10 ** decimals`
return formatTokenAmount(balance, decimals) // "1.5"
}
import { CandidDisplayReactor } from "@ic-reactor/candid"
import { ValidationError } from "@ic-reactor/core"
const reactor = new CandidDisplayReactor({
name: "transfer-actor",
canisterId,
clientManager,
candid: `service : {
transfer : (record { to : principal; amount : nat }) -> (variant { Ok : nat; Err : text });
}`,
})
await reactor.initialize()
// Add form validation (the untyped reactor passes `unknown` args)
reactor.registerValidator("transfer", (args) => {
const [input] = args as [{ to: string; amount: string }]
if (!input.to || input.to.length < 5) {
return {
success: false,
issues: [{ path: ["to"], message: "Invalid principal" }],
}
}
if (!input.amount || parseInt(input.amount) <= 0) {
return {
success: false,
issues: [{ path: ["amount"], message: "Amount must be positive" }],
}
}
return { success: true }
})
// Use in form submission
async function handleSubmit(formData: { to: string; amount: string }) {
try {
const result = await reactor.callMethod({
functionName: "transfer",
args: [formData], // Direct from form - no conversion needed!
})
console.log("Success:", result)
} catch (err) {
if (err instanceof ValidationError) {
// Show form errors
err.issues.forEach((issue) => {
console.log(`${issue.path}: ${issue.message}`)
})
}
}
}
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { CandidDisplayReactor } from "@ic-reactor/candid"
import { formatTokenAmount } from "@ic-reactor/core"
// Create a reusable hook for dynamic canister queries
function useDynamicBalance(reactor: CandidDisplayReactor, owner: string) {
return useQuery({
// The reactor builds the key: it starts with the canister ID and carries
// the reactor's display transform, so `invalidateQueries` matches it
queryKey: reactor.generateQueryKey({
functionName: "icrc1_balance_of",
args: [{ owner }],
}),
queryFn: async () => {
await reactor.registerMethod({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
})
await reactor.registerMethod({
functionName: "icrc1_decimals",
candid: "() -> (nat8) query",
})
// An untyped reactor returns `unknown`; a nat displays as a string and a
// nat8 as a number
const [balance, decimals] = (await Promise.all([
reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner }], // String input
}),
reactor.callMethod({ functionName: "icrc1_decimals" }),
])) as [string, number]
return { balance, decimals }
},
})
}
function BalanceDisplay({
canisterId,
owner,
}: {
canisterId: string
owner: string
}) {
const reactor = useMemo(
() =>
new CandidDisplayReactor({
name: `balance-${canisterId}`,
canisterId,
clientManager,
}),
[canisterId]
)
const { data, isLoading } = useDynamicBalance(reactor, owner)
if (isLoading || !data) return <div>Loading...</div>
// The balance is a string of base units, safe from bigint precision loss:
// show it with the token's decimals
return <div>Balance: {formatTokenAmount(data.balance, data.decimals)}</div>
}

Use Case Recommended Class
Dynamic form generation MetadataDisplayReactor
Dynamic result rendering with types MetadataDisplayReactor
Dynamic canister, UI display CandidDisplayReactor
Dynamic canister, raw types CandidReactor
Known canister, UI display DisplayReactor
Known canister, raw types Reactor