Skip to content
IC Reactor

DisplayReactor

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 JSON
  • Principal objects need .toText() every time you display them
  • Optional values are arrays ([value] or []) instead of value | undefined
  • Variants use object keys instead of discriminators

DisplayReactor 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 UI
const 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-prone
function 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 straightforward
function 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 DisplayReactor
const backend = new DisplayReactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend",
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
})
// Pass it to createActorHooks - all hooks use display types
const { 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:

  1. Arguments (Display → Candid): When you call a method, your display-friendly args are converted to Candid format
  2. Results (Candid → Display): When the canister responds, Candid data is converted to display format
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 }):

  • On Ok: Returns the success value directly (with display transformations applied)
  • On Err: Throws a CanisterError containing the error value
import { 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",
args: [{ name: "Alice", email: "[email protected]" }],
})
// 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 hooks
const 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 DisplayReactor
function readRawUser(user: User) {
const description = user.bio[0] ?? "No bio"
const avatar = user.avatar.length > 0 ? user.avatar[0] : null
return { description, avatar }
}
// With DisplayReactor
function 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 DisplayReactor
const rawMetadata: Array<[string, string]> = [
["key1", "val1"],
["key2", "val2"],
]
const rawValue = rawMetadata.find(([k]) => k === "key1")?.[1]
// With DisplayReactor
const 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 validators
async 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:

  • Building user interfaces
  • Working with forms
  • Displaying data in components
  • Serializing data to JSON (mind non-finite floats)
  • You want simpler type handling
  • You need client-side validation

❌ Use regular Reactor when:

  • Performing arithmetic with bigint values
  • Building backend services
  • You need raw Candid types for specific operations
  • You’re serializing to specific binary formats