Numbers
bigint → string Large numbers stay precise
DisplayReactor is a specialized reactor that serves as a bridge between Candid’s type system and your user interface. It automatically transforms IC-native types into formats that are natural to work with in JavaScript and easy to display in UIs.
Candid (the IC’s interface description language) has its own type system that doesn’t always map cleanly to JavaScript:
bigint values can’t be displayed directly in templates or serialized to JSONPrincipal objects need .toText() every time you display them[value] or []) instead of value | undefinedDisplayReactor handles all these transformations automatically, in both directions. You work with clean, JavaScript-native types in your UI, and DisplayReactor converts them to Candid when calling the canister and back to display format when receiving results.
import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
// DisplayReactor: the bridge between Candid and your UIconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId,})
// Input: JavaScript strings (easy to get from forms)// Output: Display-friendly types (easy to render)async function showAccount() { const account = await backend.callMethod({ functionName: "getAccount", args: ["aaaaa-aa"], // Principal as string ✓ }) console.log(account.balance) // "1000000" (string, not bigint) ✓}This is one of IC Reactor’s most unique and powerful features — it eliminates the need for manual type conversions between the IC and your frontend.
Candid types don’t always map directly to JavaScript types that are easy to work with in UIs:
// What the canister returns (Candid types){ balance: 1000000000n, // bigint - can't display directly owner: Principal.fromText("..."), // Principal object - need .toText() createdAt: 1703500800000000000n, // nanoseconds as bigint metadata: [["a", 1], ["b", 2]], // Array of tuples, not a Map status: { Active: null }, // Variant with null value avatar: Uint8Array([...]), // Binary data description: [], // Empty array means none (opt type)}You’d normally need to write conversion logic everywhere in your app:
import type { Account } from "./declarations/backend"
// Without DisplayReactor - tedious and error-pronefunction AccountRow({ balance, owner, status, description }: Account) { return ( <> <span>Balance: {balance.toString()}</span> <span>Owner: {owner.toText()}</span> <span>Status: {Object.keys(status)[0]}</span> <span>Description: {description[0] ?? "No description"}</span> </> )}DisplayReactor automatically transforms these types to UI-friendly formats:
// What DisplayReactor gives you{ balance: "1000000000", // string - easy to display owner: "rrkah-fqaaa-aaaaa-aaaaq-cai", // string - already formatted createdAt: "1703500800000000000", // string - parse as needed metadata: { "a": 1, "b": 2 }, // Plain Object - simple access status: { _type: "Active" }, // Normalized variant avatar: "89504e47...", // Blobs → hex string, at every size description: undefined, // undefined instead of []}Now your components are simple:
import type { ReactorDataOf } from "@ic-reactor/react"import type { backend } from "./reactor"
// With DisplayReactor - clean and straightforwardfunction AccountRow({ balance, owner, status, description,}: ReactorDataOf<typeof backend, "getAccount">) { return ( <> <span>Balance: {balance}</span> <span>Owner: {owner}</span> <span>Status: {status._type}</span> <span>Description: {description ?? "No description"}</span> </> )}import { DisplayReactor } from "@ic-reactor/core"import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { idlFactory, type _SERVICE } from "../declarations/backend"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,})Pass a DisplayReactor instance to createActorHooks to get hooks with automatic type transformations:
import { DisplayReactor, createActorHooks } from "@ic-reactor/react"import { clientManager } from "./clients"import { idlFactory, type _SERVICE } from "../declarations/backend"
// Create a DisplayReactorconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,})
// Pass it to createActorHooks - all hooks use display typesconst { useActorQuery, useActorMutation } = createActorHooks(backend)defineDisplayReactor does the whole setup in one call — QueryClient,
ClientManager, the DisplayReactor, its hooks and the Internet Identity
hooks — and takes the same options as defineReactor, plus validators:
import { defineDisplayReactor } from "@ic-reactor/react"import { idlFactory, type _SERVICE } from "../declarations/backend"
export const { reactor: backend, useActorQuery, useAuth,} = defineDisplayReactor<_SERVICE>({ name: "backend", idlFactory, canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,})It replaces defineReactor({ display: true }), which still works but is
deprecated: the flag makes every defineReactor bundle carry DisplayReactor
and zod, whether or not it is set.
Numbers
bigint → string Large numbers stay precise
Principals
Principal → string Ready for display
Optionals
[T] | [] → T | undefined JavaScript-native nullish
Blobs
vec nat8 → hex string, at every size
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
nat |
bigint |
string |
Arbitrary precision preserved |
int |
bigint |
string |
Arbitrary precision preserved |
nat64 |
bigint |
string |
64-bit integers as strings |
int64 |
bigint |
string |
64-bit integers as strings |
nat8 |
number |
number |
No change needed |
nat16 |
number |
number |
No change needed |
nat32 |
number |
number |
No change needed |
float32 |
number |
number |
NaN, ±Infinity, -0 kept; JSON loses them |
float64 |
number |
number |
NaN, ±Infinity, -0 kept; JSON loses them |
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
principal |
Principal |
string |
e.g., "rrkah-fqaaa..." |
blob |
Uint8Array |
string |
Hex, no 0x: "89504e..." |
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
opt T |
[T] | [] |
T | undefined |
None is undefined; input also takes null |
vec T |
T[] |
Display<T>[] |
Elements are transformed |
vec (text, T) |
[string, T][] |
{ [key: string]: T } |
One value per key |
record { ... } |
{ ... } |
{ ... } |
Nested fields transformed |
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
variant { A } |
{ A: null } |
{ _type: "A" } |
Normalized with _type discriminator |
variant { A: T } |
{ A: T } |
{ _type: "A", A: Display<T> } |
Value field preserved |
DisplayReactor transforms data in both directions:
async function showAccount() { // You provide display-friendly input const account = await backend.callMethod({ functionName: "getAccount", args: ["aaaaa-aa"], // Principal as string ✓ })
// You receive display-friendly output console.log(account.balance) // "1000000" (string, not bigint) console.log(account.createdAt) // "1703500800000000000" (string)}If an argument cannot be converted — a malformed principal string, a
non-numeric amount — the call throws instead of passing the untransformed
display value on to Candid encoding, so the diagnostic points at the bad field
rather than surfacing as a generic IDL.encode error raised far from the real
cause. callMethod rejects with a CallError; its cause is the conversion
error naming what failed to convert, and the original codec failure sits one
level deeper as that error’s own cause.
IC Reactor automatically unwraps Candid Result types (variant { Ok: T; Err: E }):
Ok: Returns the success value directly (with display transformations applied)Err: Throws a CanisterError containing the error valueimport { CanisterError } from "@ic-reactor/core"
// Canister method signature:// createUser : (CreateUserInput) -> (Result<User, CreateUserError>)
// Without IC Reactor, you'd need to handle:// { Ok: User } | { Err: CreateUserError }
// With IC Reactor - automatic unwrapping:async function createUser() { try { const user = await backend.callMethod({ functionName: "createUser", }) // user is directly the User object (not { Ok: User }) console.log(user.name) // "Alice" } catch (error) { if (error instanceof CanisterError) { // error.err is the CreateUserError in display form: a variant carries // a `_type`, so `{ EmailAlreadyExists: null }` arrives as // `{ _type: "EmailAlreadyExists" }` if (error.err._type === "EmailAlreadyExists") { console.log("Email taken!") } } }}This means you never have to manually check for Ok or Err variants — IC Reactor handles it for you.
import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId,})
async function getBalance() { // All returned values use display types const balance = await backend.callMethod({ functionName: "getBalance", args: ["aaaaa-aa"], // Principal as string }) // balance is string, not bigint return balance}Like Reactor, DisplayReactor gives a sibling for another canister of the
same interface with forCanister. This is
the multi-token wallet pattern: one reactor per token, all on the same agent
and cache, with the display transform and validators passed on:
import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { idlFactory, type _SERVICE } from "./declarations/icrc1"
// The reactor the siblings are made from. It still needs a canister ID of its// own: the constructor throws without one outside a local replica.const tokenReactor = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "token", canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP ledger})
async function getTokenInfo(canisterId: string) { // The same reactor each time for the same canister const token = tokenReactor.forCanister(canisterId)
// Two calls for two tokens can run at once without crossing const [name, symbol, decimals] = await Promise.all([ token.callMethod({ functionName: "icrc1_name" }), token.callMethod({ functionName: "icrc1_symbol" }), token.callMethod({ functionName: "icrc1_decimals" }), ])
// name and symbol are text; decimals, a nat8, is a number return { name, symbol, decimals }}setCanisterId still retargets one reactor in place, for code that follows a
single selection, but every call and view built on that reactor moves with it.
import { DisplayReactor, createActorHooks, formatTokenAmount,} from "@ic-reactor/react"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
// Create DisplayReactor and hooksconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId,})const { useActorQuery } = createActorHooks(backend)
function Balance({ principal }: { principal: string }) { const { data: balance } = useActorQuery({ functionName: "getBalance", args: [principal], // string, not Principal })
// balance is the nat as text, in e8s. Format it exactly; Number(balance) / 1e8 // loses digits. if (balance === undefined) return <span>…</span> return <span>{formatTokenAmount(balance, 8)} ICP</span>}See Token amounts for
formatTokenAmount and parseTokenAmount.
Candid variants are normalized for easier pattern matching:
// Candid variant: variant { Pending; Active; Completed: nat }// Raw TS: { Pending: null } | { Active: null } | { Completed: bigint }
// With DisplayReactor:type DisplayStatus = | { _type: "Pending" } | { _type: "Active" } | { _type: "Completed"; Completed: string }
function StatusBadge({ status }: { status: DisplayStatus }) { switch (status._type) { case "Pending": return <span className="badge yellow">Pending</span> case "Active": return <span className="badge green">Active</span> case "Completed": return <span className="badge blue">Done at {status.Completed}</span> }}No more array-based optional checking:
import type { ReactorDataOf } from "@ic-reactor/react"import type { User } from "./declarations/backend"import type { backend } from "./reactor"
// Without DisplayReactorfunction readRawUser(user: User) { const description = user.bio[0] ?? "No bio" const avatar = user.avatar.length > 0 ? user.avatar[0] : null return { description, avatar }}
// With DisplayReactorfunction readUser(user: ReactorDataOf<typeof backend, "getUser">) { const description = user.bio ?? "No bio" const avatar = user.avatar // Already undefined or value return { description, avatar }}Every blob (vec nat8) decodes to a lowercase hex string with no 0x
prefix, whatever its size. One Candid type, one JavaScript type — hex text is
always JSON-safe, so persisting or serialising a display value never corrupts
binary fields.
import { hexToUint8Array } from "@ic-reactor/core"import type { ReactorDataOf } from "@ic-reactor/react"import { useActorMutation, type backend } from "./reactor"
// Candid: blob (vec nat8)export function useBlobs( account: ReactorDataOf<typeof backend, "getAccount">, tx: ReactorDataOf<typeof backend, "getTransaction">) { // Every blob is a hex string (hash, signature, icon, image — any size) const hash = tx.hash // "2cf24dba5fb0a30e..." const signature = tx.signature // "3045022100ab..." const imageData = account.avatar // "89504e470d0a1a0a..." (hex too)
// Need the bytes back? hexToUint8Array is exported from the package. const bytes = hexToUint8Array(imageData)
// When sending blobs back, you can use either format: const { mutate } = useActorMutation({ functionName: "saveDocument" }) const save = () => mutate([ { // Hex string input (DisplayReactor converts to Uint8Array) hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c",
// Or Uint8Array directly data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), }, ])
return { hash, signature, bytes, save }}float32 and float64 display as JavaScript numbers, and every value a
canister can return, NaN, Infinity, -Infinity and -0 included, is sent
back as the same bytes. As an argument a float is also taken as the text a form
holds, such as "3.14", which must spell a finite number: "", "abc",
"Infinity" and "NaN" are refused, as the form schemas of @ic-reactor/candid
refuse them. A finite number that float32 cannot hold, such as 3.5e38, is
refused too, since Candid encoding would send it as Infinity.
Candid’s vec (text, T) becomes a plain JavaScript Object:
// Without DisplayReactorconst rawMetadata: Array<[string, string]> = [ ["key1", "val1"], ["key2", "val2"],]const rawValue = rawMetadata.find(([k]) => k === "key1")?.[1]
// With DisplayReactorconst metadata = { key1: "val1", key2: "val2",}const value = metadata["key1"]As an argument, the map is also taken as the array of pairs or as a Map.
Each is sent as its entries in its own order: an object’s, the array’s, or the
Map’s insertion order. The argument’s TypeScript type names only the object,
so the other two need a cast.
Display types work seamlessly with form inputs:
import { useState } from "react"import { useActorMutation } from "./reactor"
function TransferForm() { const [recipient, setRecipient] = useState("") const [amount, setAmount] = useState("")
const { mutate } = useActorMutation({ functionName: "transfer", })
const handleSubmit = (e: React.FormEvent) => { e.preventDefault() // Strings work directly - no BigInt conversion needed! mutate([{ to: recipient, amount }]) }
return ( <form onSubmit={handleSubmit}> <input value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="Principal ID" /> <input type="text" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount" /> <button type="submit">Transfer</button> </form> )}This amount field takes base units (e8s). A field a person types a token
amount into, such as "1.5", is read with
parseTokenAmount:
mutate([{ to: recipient, amount: parseTokenAmount(amount, decimals).toString() }]). It throws
on text it cannot read exactly instead of rounding it.
For advanced use cases, access the underlying codec:
import type { Account } from "./declarations/backend"
function convertManually(displayArg: string, candidResult: Account) { const codec = backend.getCodec("getAccount")
if (codec) { // Transform display → candid manually. A method with one argument takes // that argument itself, one with several a tuple, and one with none `null` const candidArg = codec.args.asCandid(displayArg)
// Transform candid → display manually const displayResult = codec.result.asDisplay(candidResult) }}DisplayReactor maintains full type safety. TypeScript knows the transformed types:
import { useActorQuery } from "./reactor"
function AccountBalance({ owner }: { owner: string }) { // TypeScript infers the correct display types const { data } = useActorQuery({ functionName: "getAccount", args: [owner], // string (was Principal) })
// data.balance is string (was bigint) // data.createdAt is string (was bigint) // data.description is string | null | undefined (was [string] | []); none arrives as undefined return <span>{data?.balance}</span>}The display types come from the service type parameter
(new DisplayReactor<_SERVICE>(...)). Without one, the reactor is typed by
BaseActor and knows nothing about the Candid, so every method’s display-side
arguments and results are unknown: validators receive unknown args, and
results must be narrowed before they are rendered. (An untyped Reactor types
the same values as any, because the raw side passes BaseActor’s any
through.) Pass the service type from your generated declarations, or cast to
the display shape you expect where the interface is only known at run time, as
with a CandidDisplayReactor.
DisplayReactor includes optional argument validation. Validators receive display types, making them perfect for form validation.
import { DisplayReactor } from "@ic-reactor/core"import { clientManager } from "./clients"import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId,})
// Validators receive display types (strings!)backend.registerValidator("transfer", ([input]) => { const issues = []
// input.to is string (not Principal) if (!input.to || input.to.length === 0) { 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 }})Use validate() to check before calling the canister:
async function submit() { const result = await backend.validate("transfer", [formData])
if (!result.success) { result.issues.forEach((issue) => { form.setError(issue.path[0], issue.message) }) return }
// Validation passed, make the call await backend.callMethod({ functionName: "transfer", args: [formData] })}Use fromZodSchema for type-safe validation:
import { z } from "zod"import { fromZodSchema, DisplayReactor } from "@ic-reactor/core"
const transferSchema = z.object({ to: z.string().min(1, "Recipient is required"), amount: z.string().regex(/^\d+$/, "Must be a valid number"),})
backend.registerValidator("transfer", fromZodSchema(transferSchema))The schema checks the method’s first argument. zod refuses a schema with an
async refinement or transform on its synchronous path, so pass
{ async: true } for one: the validator then parses with safeParseAsync and
returns a promise, and runs where async validators run (below).
import { z } from "zod"import { fromZodSchema } from "@ic-reactor/core"
const recipientSchema = z.object({ to: z .string() .refine(async (to) => !(await isBlocked(to)), "Address is blocked"),})
backend.registerValidator( "transfer", fromZodSchema(recipientSchema, { async: true }))For async validation (e.g., checking a blocklist), use validate() or
callMethodWithValidation:
backend.registerValidator("transfer", async ([input]) => { const isBlocked = await checkBlocklist(input.to) if (isBlocked) { return { success: false, issues: [{ path: ["to"], message: "Address is blocked" }], } } return { success: true }})
// Use callMethodWithValidation for async validatorsasync function submit() { await backend.callMethodWithValidation({ functionName: "transfer", args: [formData], })}callMethod() runs validators synchronously, and so do the query and mutation
hooks and factories that call it. They refuse a call whose validator returns a
promise, with a CallError that names the method, without sending anything.
A validator that throws or rejects (a blocklist lookup that fails, say) is
reported the same way by callMethod(), callMethodWithValidation() and
validate(): as a CallError whose cause is what the validator threw, and
the canister is not called. It is not a verdict on the arguments, so it is not
a ValidationError, and it is not the canister’s answer, so a CanisterError
the validator’s own lookup threw is not passed on as one. A ValidationError
the validator throws is passed on as it is.
import { isCallError, isValidationError } from "@ic-reactor/core"
async function submit() { try { await backend.callMethodWithValidation({ functionName: "transfer", args: [formData], }) } catch (error) { if (isValidationError(error)) { // the validator's verdict: error.issues } else if (isCallError(error)) { // a failed call, or a validator that threw: error.cause } }}✅ Use DisplayReactor when:
❌ Use regular Reactor when: