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 | nullDisplayReactor 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.
// DisplayReactor: the bridge between Candid and your UIconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, canisterId,})
// Input: JavaScript strings (easy to get from forms)// Output: Display-friendly types (easy to render)const user = await backend.callMethod({ functionName: "getUser", args: ["aaaaa-aa"], // Principal as string ✓})console.log(user.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 "null" (opt type)}You’d normally need to write conversion logic everywhere in your app:
// Without DisplayReactor - tedious and error-prone<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: Map { "a" => 1, "b" => 2 }, // JavaScript Map - iterable status: { _type: "Active" }, // Normalized variant avatar: "0x89504e47...", // Small blobs → hex string description: null, // null instead of []}Now your components are simple:
// With DisplayReactor - clean and straightforward<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 "./client"import { idlFactory, type _SERVICE } from "../declarations/backend"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, 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 "./client"import { idlFactory, type _SERVICE } from "../declarations/backend"
// Create a DisplayReactorconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,})
// Pass it to createActorHooks - all hooks use display typesconst { useActorQuery, useActorMutation, reactor } = createActorHooks(backend)Numbers
bigint → string Large numbers stay precise
Principals
Principal → string Ready for display
Optionals
[T] | [] → T | null JavaScript-native nullish
Blobs
Small → hex string Large → Uint8Array
| 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 | No change needed |
float64 | number | number | No change needed |
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
principal | Principal | string | e.g., "rrkah-fqaaa..." |
blob ≤96 bytes | Uint8Array | string | Hex format: "0x89504e..." |
blob >96 bytes | Uint8Array | Uint8Array | Unchanged (avoids huge strings) |
| Candid | TypeScript | Display | Notes |
|---|---|---|---|
opt T | [T] | [] | T | null | Nullish instead of empty array |
vec T | T[] | Display<T>[] | Elements are transformed |
vec (text, T) | [string, T][] | Map<string, T> | Key-value pairs as Map |
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:
// You provide display-friendly inputconst user = await backend.callMethod({ functionName: "getUser", args: ["aaaaa-aa"], // Principal as string ✓})
// You receive display-friendly outputconsole.log(user.balance) // "1000000" (string, not bigint)console.log(user.createdAt) // "1703500800000000000" (string)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 value// 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:try { const user = await backend.callMethod({ functionName: "createUser", args: [{ name: "Alice", email: "alice@example.com" }], }) // user is directly the User object (not { Ok: User }) console.log(user.name) // "Alice"} catch (error) { if (error instanceof CanisterError) { // error.err contains the CreateUserError if ("EmailAlreadyExists" in error.err) { 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"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, canisterId: "...",})
// All returned values use display typesconst balance = await backend.callMethod({ functionName: "getBalance", args: ["aaaaa-aa"], // Principal as string})// balance is string, not bigint// Create DisplayReactor and hooksconst backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, canisterId })const { useActorQuery } = createActorHooks(backend)
function Balance({ principal }: { principal: string }) { const { data: balance } = useActorQuery({ functionName: "getBalance", args: [principal], // string, not Principal })
// balance is string, ready for display return <span>{balance} ICP</span>}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:
// Without DisplayReactorconst description = user.bio[0] ?? "No bio"const avatar = user.avatar.length > 0 ? user.avatar[0] : null
// With DisplayReactorconst description = user.bio ?? "No bio"const avatar = user.avatar // Already null or valueBlobs (vec nat8) are handled intelligently based on size:
Uint8Array to avoid huge hex strings// Candid: blob (vec nat8)
// Small blob example (e.g., a hash, signature, or small icon)const hash = user.passwordHash // "0x2cf24dba5fb0a30e..."const signature = tx.signature // "0x3045022100ab..."
// Large blob example (e.g., image data)const imageData = user.avatar // Uint8Array (stays as-is)
// When sending blobs back, you can use either format:mutate([ { // Hex string input (DisplayReactor converts to Uint8Array) hash: "0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c",
// Or Uint8Array directly data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), },])Candid’s vec (text, T) becomes a real JavaScript Map:
// Without DisplayReactorconst metadata: Array<[string, string]> = [ ["key1", "val1"], ["key2", "val2"],]const value = metadata.find(([k]) => k === "key1")?.[1]
// With DisplayReactorconst metadata: Map<string, string> = new Map([ ["key1", "val1"], ["key2", "val2"],])const value = metadata.get("key1")Display types work seamlessly with form inputs:
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([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> )}For advanced use cases, access the underlying codec:
const codec = backend.getCodec("getUser")
if (codec) { // Transform display → candid manually const candidArgs = codec.args.asCandid(displayArgs)
// Transform candid → display manually const displayResult = codec.result.asDisplay(candidResult)}DisplayReactor maintains full type safety. TypeScript knows the transformed types:
// TypeScript infers the correct display typesconst { data } = useActorQuery({ functionName: "getUser", args: [userId], // string (was Principal)})
// data.balance is string (was bigint)// data.createdAt is string (was bigint)// data.bio is string | null (was [string] | [])DisplayReactor includes optional argument validation. Validators receive display types, making them perfect for form validation.
import { DisplayReactor } from "@ic-reactor/core"
const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, 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:
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 callawait 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))For async validation (e.g., checking a blocklist), use 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 validatorsawait backend.callMethodWithValidation({ functionName: "transfer", args: [formData],})✅ Use DisplayReactor when:
❌ Use regular Reactor when: