Skip to content
IC Reactor

MetadataDisplayReactor

The MetadataDisplayReactor is a powerful system for working with Internet Computer (IC) canisters that provides runtime metadata generation for building dynamic forms and rendering results. It enables you to create form interfaces that adapt to any canister’s Candid interface without compile-time type definitions.

MetadataDisplayReactor solves a fundamental challenge: How do you build a UI for a canister when you don’t know its interface at compile time?

It provides:

  • Dynamic Candid parsing - Parse canister interfaces at runtime
  • Form metadata generation - Generate comprehensive metadata for building input forms
  • Result resolution - Transform raw canister responses into display-friendly structures
  • Type transformations - Convert between Candid types (bigint, Principal) and UI types (strings)
  • Validation schemas - Built-in Zod schemas for each field type
DisplayReactor (from @ic-reactor/core)
└── CandidDisplayReactor (display-reactor.ts)
└── MetadataDisplayReactor (metadata-display-reactor.ts)
  1. Visitor Pattern - Traverses Candid IDL types to generate metadata
  2. Stateless Visitors - Visitors are reusable and generate metadata at initialization
  3. Form-Framework Agnostic - Output works with TanStack Form, React Hook Form, or vanilla JS
  4. Display Type Transformations - bigint → string, Principal → string for easy UI handling
  5. Lazy Evaluation - Recursive types use lazy extraction to prevent infinite loops

When you call getInputMeta(methodName), you receive comprehensive metadata for building input forms:

interface ArgumentsMeta<A, Name> {
functionType: "query" | "update"
functionName: Name
args: FieldNode[] // One FieldNode per argument
defaults: unknown[] // Default values for form initialization
schema: z.ZodTuple // Zod schema for validation
argCount: number
isEmpty: boolean // True if method takes no arguments
}

Each FieldNode contains everything needed to render a form field:

interface FieldNode {
type: VisitorDataType // "record" | "variant" | "text" | "number" | etc.
label: string // Raw Candid label (e.g., "__arg0", "owner")
displayLabel: string // Human-readable label (e.g., "Arg 0", "Owner")
name: string // TanStack Form path (e.g., "[0]", "[0].owner")
component: FieldComponentType // Suggested component
renderHint: RenderHint // UI hints
defaultValue: unknown // Initial form value
schema: z.ZodTypeAny // Zod validation schema
candidType: string // Original Candid type name
// Plus type-specific extras (fields, options, etc.)
}
Type Component Description
record record-container Nested object with named fields
variant variant-select Discriminated union (like enum with data)
tuple tuple-container Fixed-length array
optional optional-toggle Nullable field
vector vector-list Dynamic array
blob blob-upload Binary data (vec nat8)
recursive recursive-lazy Self-referential type
principal principal-input IC Principal
text text-input String
number number-input Integer or float
boolean boolean-checkbox Boolean
null null-hidden Null type

Each field type includes additional properties relevant to its nature:

interface RecordField {
type: "record"
fields: FieldNode[] // Child fields
defaultValue: Record<string, unknown> // Object default
}

When you call getOutputMeta(methodName), you receive metadata for rendering results:

interface MethodMeta<A, Name> {
functionType: FunctionType
functionName: Name
returns: ResultNode[] // Schema for return values
returnCount: number
resolve(data): MethodResult<A> // Transform raw → display
}
interface ResultNode {
type: VisitorDataType // Same types as arguments
label: string // Field label
displayLabel: string // Human-readable label
candidType: string // Original Candid type
displayType: DisplayType // "string" | "number" | "object" | "array" | etc.
resolve(data): ResolvedNode // Transform raw value
}
DisplayType Description
string Text, Principal, large numbers (as string)
number Small integers, floats
boolean Boolean values
null Null values
object Records
array Vectors, Tuples
variant Variant types
result Special case for Ok/Err variants
nullable Optional types
recursive Self-referential types
func Function references (canister ID + method)

Blob nodes keep type: "blob" but resolve with displayType: "string" — the value is always a hex string, and length is the byte count. Key any blob-specific rendering (byte count, hash summary) on node.type.

The resolve() method transforms raw canister responses into display-ready structures:

// Raw canister response
const rawResult = { Ok: 1000000000n } // bigint
// Get metadata
const meta = reactor.getOutputMeta("icrc1_transfer")
// Resolve to display types
const resolved = meta.resolve(rawResult)
// resolved = {
// functionType: "update",
// functionName: "icrc1_transfer",
// results: [{
// type: "variant",
// selected: "Ok",
// selectedValue: { // The resolved value of the selected option
// type: "number",
// value: "1000000000", // String, not bigint!
// displayType: "string"
// },
// raw: { Ok: 1000000000n }
// }],
// raw: { Ok: 1000000000n }
// }

Candid func types can appear as data fields in records, commonly seen in callback references:

// Raw canister response with func reference
const rawResult = {
callback: [Principal.fromText("sa4so-piaaa-aaaar-qacnq-cai"), "get_transactions"]
}
// The callback field is resolved to:
{
type: "func",
displayType: "func",
canisterId: "sa4so-piaaa-aaaar-qacnq-cai",
methodName: "get_transactions",
raw: [Principal, "get_transactions"]
}

The system automatically detects formats from field labels:

Format Detected From
timestamp created_at, updated_at, timestamp_nanos, etc.
email email, mail
url url, link, website
phone phone, tel, mobile
uuid uuid, guid
btc btc, bitcoin
eth eth, ethereum
principal principal, canister
account-id account_identifier, ledger_account
Format Detected From
timestamp time, date, created_at, etc.
cycle cycle, cycles
normal Everything else

Create the reactor with new, then initialize it:

import { ClientManager } from "@ic-reactor/core"
import { MetadataDisplayReactor } from "@ic-reactor/candid"
import { QueryClient } from "@tanstack/query-core"
// 1. Create client manager
const clientManager = new ClientManager({
queryClient: new QueryClient(),
})
await clientManager.initialize()
// 2. Create reactor
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
// 3. Initialize (fetches Candid from canister)
await reactor.initialize()
// 4. Get available methods
const methods = reactor.getMethodNames()
console.log(methods) // ["icrc1_balance_of", "icrc1_transfer", ...]
// Get input metadata for a method
const inputMeta = reactor.getInputMeta("icrc1_transfer")
console.log(inputMeta.functionName) // "icrc1_transfer"
console.log(inputMeta.functionType) // "update"
console.log(inputMeta.argCount) // 1
console.log(inputMeta.defaults) // [{ to: { owner: "", subaccount: null }, ... }]
// Iterate over args to build form
for (const field of inputMeta.args) {
console.log(field.type) // "record"
console.log(field.displayLabel) // "Arg 0"
console.log(field.component) // "record-container"
// For records, access nested fields
if (field.type === "record") {
for (const childField of field.fields) {
console.log(childField.name) // "[0].to", "[0].amount", etc.
console.log(childField.displayLabel) // "To", "Amount", etc.
}
}
}
import { useForm } from '@tanstack/react-form'
// Get metadata
const inputMeta = reactor.getInputMeta("icrc1_transfer")
// Create form with metadata
const form = useForm({
defaultValues: inputMeta.defaults,
validators: {
onBlur: inputMeta.schema // Zod schema
},
onSubmit: async ({ value }) => {
const result = await reactor.callMethod({
functionName: "icrc1_transfer",
args: value // Use form values directly
})
console.log(result)
}
})
// Render fields dynamically
function DynamicForm() {
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
{inputMeta.args.map((field, index) => (
<form.Field key={index} name={`[${index}]`}>
{(fieldApi) => (
<DynamicFieldInput field={field} fieldApi={fieldApi} />
)}
</form.Field>
))}
<button type="submit">Call Method</button>
</form>
)
}
import { isFieldType, isCompoundField } from "@ic-reactor/candid"
function DynamicFieldInput({ field, fieldApi }) {
// Handle compound types
if (isFieldType(field, "record")) {
return (
<fieldset>
<legend>{field.displayLabel}</legend>
{field.fields.map((childField, i) => (
<fieldApi.Field key={i} name={childField.label}>
{(childApi) => (
<DynamicFieldInput field={childField} fieldApi={childApi} />
)}
</fieldApi.Field>
))}
</fieldset>
)
}
if (isFieldType(field, "variant")) {
const selectedKey = field.getSelectedKey(fieldApi.state.value)
return (
<div>
<label>{field.displayLabel}</label>
<select
value={selectedKey}
onChange={(e) => {
fieldApi.handleChange(field.getOptionDefault(e.target.value))
}}
>
{field.options.map((opt) => (
<option key={opt.label} value={opt.label}>
{opt.displayLabel}
</option>
))}
</select>
{/* Render selected option's field */}
{field.getSelectedOption(fieldApi.state.value).type !== "null" && (
<DynamicFieldInput
field={field.getSelectedOption(fieldApi.state.value)}
fieldApi={fieldApi}
/>
)}
</div>
)
}
if (isFieldType(field, "vector")) {
const items = fieldApi.state.value || []
return (
<div>
<label>{field.displayLabel}</label>
{items.map((_, i) => (
<fieldApi.Field key={i} name={`[${i}]`}>
{(itemApi) => (
<DynamicFieldInput
field={field.createItemField(i)}
fieldApi={itemApi}
/>
)}
</fieldApi.Field>
))}
<button
type="button"
onClick={() => fieldApi.pushValue(field.getItemDefault())}
>
Add Item
</button>
</div>
)
}
if (isFieldType(field, "optional")) {
const isEnabled = field.isEnabled(fieldApi.state.value)
return (
<div>
<label>
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => {
fieldApi.handleChange(
e.target.checked ? field.getInnerDefault() : null
)
}}
/>
{field.displayLabel}
</label>
{isEnabled && (
<DynamicFieldInput field={field.innerField} fieldApi={fieldApi} />
)}
</div>
)
}
// Primitive types
if (isFieldType(field, "text") || isFieldType(field, "principal")) {
return (
<div>
<label>{field.displayLabel}</label>
<input
{...field.inputProps}
value={fieldApi.state.value ?? ""}
onChange={(e) => fieldApi.handleChange(e.target.value)}
/>
</div>
)
}
if (isFieldType(field, "number")) {
return (
<div>
<label>{field.displayLabel}</label>
<input
{...field.inputProps}
value={fieldApi.state.value ?? ""}
onChange={(e) => fieldApi.handleChange(e.target.value)}
/>
{field.format === "timestamp" && (
<small>Timestamp (nanoseconds)</small>
)}
</div>
)
}
if (isFieldType(field, "boolean")) {
return (
<label>
<input
type="checkbox"
checked={fieldApi.state.value ?? false}
onChange={(e) => fieldApi.handleChange(e.target.checked)}
/>
{field.displayLabel}
</label>
)
}
return <span>Unsupported type: {field.type}</span>
}
// Call method and get resolved result
const result = await reactor.callMethod({
functionName: "icrc1_balance_of",
args: [{ owner: "aaaaa-aa" }],
})
// result is already resolved through transformResult
console.log(result)
// {
// functionType: "query",
// functionName: "icrc1_balance_of",
// results: [{ type: "number", value: "1000000000", displayType: "string", raw: 1000000000n }],
// raw: 1000000000n
// }
// The resolved result keeps the raw canister value, so you can resolve it
// again yourself with the same metadata
const outputMeta = reactor.getOutputMeta("icrc1_balance_of")
const resolved = outputMeta?.resolve(result.raw)
function ResultDisplay({ resolved }) {
if (!resolved) return null
return (
<div>
<h3>{resolved.functionName} ({resolved.functionType})</h3>
{resolved.results.map((node, i) => (
<ResultNode key={i} node={node} />
))}
</div>
)
}
function ResultNode({ node }) {
// Blob nodes resolve with displayType "string" — the value is always a hex
// string, whatever the payload size — so key their compact byte-count
// rendering on the node's own type instead.
if (node.type === "blob") {
return (
<div>
<strong>{node.label}:</strong>
<code>
{node.length} bytes (hash: {node.hash.slice(0, 16)}...)
</code>
</div>
)
}
switch (node.displayType) {
case "string":
case "number":
return (
<div>
<strong>{node.label}:</strong> {node.value}
</div>
)
case "boolean":
return (
<div>
<strong>{node.label}:</strong> {node.value ? "Yes" : "No"}
</div>
)
case "null":
return (
<div>
<strong>{node.label}:</strong> <em>null</em>
</div>
)
case "object":
return (
<div>
<strong>{node.label}:</strong>
<div style={{ marginLeft: 20 }}>
{Object.entries(node.fields).map(([key, field]) => (
<ResultNode key={key} node={field} />
))}
</div>
</div>
)
case "array":
return (
<div>
<strong>{node.label}:</strong>
<ul>
{node.items.map((item, i) => (
<li key={i}>
<ResultNode node={item} />
</li>
))}
</ul>
</div>
)
case "result":
case "variant":
return (
<div>
<strong>{node.label}:</strong> <em>{node.selected}</em>
<div style={{ marginLeft: 20 }}>
<ResultNode node={node.selectedValue} />
</div>
</div>
)
case "nullable":
if (node.value === null) {
return (
<div>
<strong>{node.label}:</strong> <em>not set</em>
</div>
)
}
return <ResultNode node={node.value} />
case "func":
return (
<div>
<strong>{node.label}:</strong>
<span>Callback: {node.canisterId}.{node.methodName}()</span>
</div>
)
default:
return (
<div>
<strong>{node.label}:</strong>
<pre>{JSON.stringify(node.raw, null, 2)}</pre>
</div>
)
}
}

You can register methods at runtime with Candid signatures:

// Register a method not in the original service
await reactor.registerMethod({
functionName: "custom_method",
candid: "(record { input: text }) -> (record { output: text }) query",
})
// Metadata is automatically regenerated
const argMeta = reactor.getInputMeta("custom_method")
const resultMeta = reactor.getOutputMeta("custom_method")
// Call the newly registered method
const result = await reactor.callMethod({
functionName: "custom_method",
args: [{ input: "hello" }],
})

Combine registration and calling in one step:

const { result, meta } = await reactor.callDynamicWithMeta({
functionName: "get_info",
candid: "() -> (record { name: text; version: nat }) query",
args: [],
})
// meta contains the result metadata
// result is already resolved

The library provides utility functions for working with fields:

import {
isFieldType,
isCompoundField,
isPrimitiveField,
hasChildFields,
hasOptions,
formatLabel
} from "@ic-reactor/candid"
// Type guards
if (isFieldType(field, "record")) {
// field is typed as RecordField
field.fields.forEach(...)
}
if (isCompoundField(field)) {
// field is record | variant | tuple | optional | vector | recursive
}
if (isPrimitiveField(field)) {
// field is principal | number | text | boolean | null
}
if (hasChildFields(field)) {
// field has .fields array (record, tuple)
}
if (hasOptions(field)) {
// field has .options array (variant)
}
// Label formatting
formatLabel("__arg0") // "Arg 0"
formatLabel("_0_") // "Item 0"
formatLabel("created_at") // "Created At"
formatLabel("userAddress") // "User Address"

MetadataDisplayReactor is fully typed. When you have a typed actor, the generics flow through:

import type { _SERVICE } from "./declarations/my_canister"
const reactor = new MetadataDisplayReactor<_SERVICE>({
canisterId: "...",
clientManager,
name: "MyCanister"
})
// Method names are type-checked
const meta = reactor.getInputMeta("my_method") // TypeScript knows the method names
// Return types are inferred
const result = await reactor.callMethod({
functionName: "my_method",
args: [...]
})

Complete Example: Generic Canister Explorer

Section titled “Complete Example: Generic Canister Explorer”

Here’s a complete example that creates a UI for any canister:

import { ClientManager } from "@ic-reactor/core"
import { MetadataDisplayReactor, isFieldType } from "@ic-reactor/candid"
import { QueryClient } from "@tanstack/query-core"
async function createCanisterExplorer(canisterId: string) {
// Setup
const clientManager = new ClientManager({
queryClient: new QueryClient(),
})
await clientManager.initialize()
const reactor = new MetadataDisplayReactor({
canisterId,
clientManager,
name: "Explorer",
})
await reactor.initialize()
// Get all methods
const methods = reactor.getMethodNames()
// Build method info
const methodInfo = methods.map((name) => {
const inputMeta = reactor.getInputMeta(name)
const outputMeta = reactor.getOutputMeta(name)
return {
name,
type: inputMeta?.functionType,
argCount: inputMeta?.argCount ?? 0,
returnCount: outputMeta?.returnCount ?? 0,
defaultValues: inputMeta?.defaults,
schema: inputMeta?.schema,
}
})
// Example: Call a query method
async function callMethod(methodName: string, args: unknown[]) {
const result = await reactor.callMethod({
functionName: methodName as any,
args: args as any,
})
return result
}
return {
methods: methodInfo,
getInputMeta: (name: string) => reactor.getInputMeta(name as any),
getOutputMeta: (name: string) => reactor.getOutputMeta(name as any),
callMethod,
}
}
// Usage
const explorer = await createCanisterExplorer("ryjl3-tyaaa-aaaaa-aaaba-cai")
console.log("Available methods:", explorer.methods)
// Get form metadata for icrc1_balance_of
const balanceInputMeta = explorer.getInputMeta("icrc1_balance_of")
console.log("Form fields:", balanceInputMeta?.args)
// Call with display types (strings)
const balance = await explorer.callMethod("icrc1_balance_of", [
{ owner: "aaaaa-aa" },
])
console.log("Balance:", balance)

Initialize Once

Call initialize() once and reuse the reactor. Metadata generation happens at initialization.

Use Type Guards

Use isFieldType() and other helpers for type-safe field handling.

Leverage Defaults

Use defaultValues from metadata to initialize forms properly.

Use Zod Schemas

The built-in schemas integrate with most form libraries for validation.

Method Description
initialize() Parse Candid and generate metadata
getMethodNames() Get list of available method names
getInputMeta(name) Get input form metadata for a method
getOutputMeta(name) Get output display metadata for a method
getAllInputMeta() Get input metadata for all methods
getAllOutputMeta() Get output metadata for all methods
registerMethod(options) Register a dynamic method
callMethod(options) Call a method with display type transformation
callDynamicWithMeta(options) Register, call, and return metadata
Property Type Description
type VisitorDataType Field type identifier
label string Raw Candid label
displayLabel string Human-readable label
name string Form path (TanStack compatible)
component FieldComponentType Suggested component
renderHint RenderHint UI rendering hints
defaultValue unknown Initial value
schema z.ZodTypeAny Validation schema
candidType string Original Candid type
Property Type Description
type VisitorDataType Node type identifier
label string Field label
displayLabel string Human-readable label
candidType string Original Candid type
displayType DisplayType Display category
resolve(data) Function Transform raw to display