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.
Overview
Section titled “Overview”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
Section titled “Import”import { CandidDisplayReactor } from "@ic-reactor/candid"Constructor
Section titled “Constructor”new CandidDisplayReactor<A = BaseActor, T extends TransformKey = "display">( config: CandidDisplayReactorParameters)Parameters
Section titled “Parameters”| 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 |
Initialization Options
Section titled “Initialization Options”import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({ name: "dynamic-actor", canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", clientManager,})
// Fetches Candid from canister metadataawait reactor.initialize()
// Now call with display typesconst balance = await reactor.callMethod({ functionName: "icrc1_balance_of", args: [{ owner: "aaaaa-aa" }], // string, not Principal!})// balance is "1000000" (string, not bigint)import { CandidDisplayReactor } from "@ic-reactor/candid"
const reactor = new CandidDisplayReactor({ name: "ledger", canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", clientManager, candid: `service : { icrc1_name : () -> (text) query; icrc1_balance_of : (record { owner : principal }) -> (nat) query; }`,})
// Parses provided Candid (no network fetch)await reactor.initialize()Types: with and without a service type
Section titled “Types: with and without a service type”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 stringfor anatresult. 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/bindgengenerates 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 castreactor.registerValidator("transfer", ([input]) => input.to ? { success: true } : { success: false, issues: [{ path: ["to"], message: "Required" }] })
// balance is stringconst 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.
Type Transformations
Section titled “Type Transformations”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) |
Methods
Section titled “Methods”initialize()
Section titled “initialize()”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.
registerMethod(options)
Section titled “registerMethod(options)”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)registerMethods(methods)
Section titled “registerMethods(methods)”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 validatorreactor.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}Validation Methods
Section titled “Validation Methods”| 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 |
One-Shot Dynamic Calls
Section titled “One-Shot Dynamic Calls”For quick calls without explicit registration:
queryDynamic(options)
Section titled “queryDynamic(options)”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)callDynamic(options)
Section titled “callDynamic(options)”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!})fetchQueryDynamic(options)
Section titled “fetchQueryDynamic(options)”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 resultExamples
Section titled “Examples”UI-Friendly Token Balance
Section titled “UI-Friendly Token Balance”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"}Form with Validation
Section titled “Form with Validation”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 submissionasync 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}`) }) } }}React Integration
Section titled “React Integration”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 queriesfunction 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>}When to Use
Section titled “When to Use”| 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 |
See Also
Section titled “See Also”- CandidReactor — Dynamic Reactor without display transformations
- DisplayReactor — Static Reactor with display transformations
- CandidAdapter — Low-level Candid utilities
- @ic-reactor/candid Overview — Package overview