Skip to content

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.)
}
TypeComponentDescription
recordrecord-containerNested object with named fields
variantvariant-selectDiscriminated union (like enum with data)
tupletuple-containerFixed-length array
optionaloptional-toggleNullable field
vectorvector-listDynamic array
blobblob-uploadBinary data (vec nat8)
recursiverecursive-lazySelf-referential type
principalprincipal-inputIC Principal
texttext-inputString
numbernumber-inputInteger or float
booleanboolean-checkboxBoolean
nullnull-hiddenNull 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
}
DisplayTypeDescription
stringText, Principal, large numbers (as string)
numberSmall integers, floats
booleanBoolean values
nullNull values
objectRecords
arrayVectors, Tuples
variantVariant types
resultSpecial case for Ok/Err variants
nullableOptional types
recursiveSelf-referential types
blobBinary data with hash
funcFunction references (canister ID + method)

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:

FormatDetected From
timestampcreated_at, updated_at, timestamp_nanos, etc.
emailemail, mail
urlurl, link, website
phonephone, tel, mobile
uuiduuid, guid
btcbtc, bitcoin
etheth, ethereum
principalprincipal, canister
account-idaccount_identifier, ledger_account
FormatDetected From
timestamptime, date, created_at, etc.
cyclecycle, cycles
normalEverything else

There are two ways to create a MetadataDisplayReactor:

import { ClientManager } from "@ic-reactor/core"
import { createMetadataReactor } from "@ic-reactor/candid"
import { QueryClient } from "@tanstack/query-core"
// 1. Create client manager
const clientManager = new ClientManager({
queryClient: new QueryClient(),
withProcessEnv: true,
})
await clientManager.initialize()
// 2. Create and initialize reactor in one step
const reactor = await createMetadataReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
// 3. 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
// }
// Or get metadata and resolve manually
const outputMeta = reactor.getOutputMeta("icrc1_balance_of")
const rawResult = await actor.icrc1_balance_of({ owner: principal })
const resolved = outputMeta.resolve(rawResult)
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 }) {
// Handle by display type
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 "blob":
return (
<div>
<strong>{node.label}:</strong>
<code>
{node.length} bytes (hash: {node.hash.slice(0, 16)}...)
</code>
</div>
)
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(),
withProcessEnv: true,
})
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.

MethodDescription
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
PropertyTypeDescription
typeVisitorDataTypeField type identifier
labelstringRaw Candid label
displayLabelstringHuman-readable label
namestringForm path (TanStack compatible)
componentFieldComponentTypeSuggested component
renderHintRenderHintUI rendering hints
defaultValueunknownInitial value
schemaz.ZodTypeAnyValidation schema
candidTypestringOriginal Candid type
PropertyTypeDescription
typeVisitorDataTypeNode type identifier
labelstringField label
displayLabelstringHuman-readable label
candidTypestringOriginal Candid type
displayTypeDisplayTypeDisplay category
resolve(data)FunctionTransform raw to display