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, and nat, int, nat64, int64
number number-input Float, or an integer of up to 32 bits
boolean boolean-checkbox Boolean
null null-hidden Null type

nat, int, nat64 and int64 arguments are text fields, because a JavaScript number cannot hold every value: the form keeps them as decimal strings. Their candidType names the integer type, and their inputProps set inputMode: "numeric" and a digits-only pattern.

Each field type includes additional properties relevant to its nature:

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

A plain text field’s schema takes any string, "" included: Candid text has no required-ness, and a method may take an empty one. A text field whose label gives it a format (see Format Detection) keeps that format’s check and rejects "", the numbers held as text still need digits, and a func reference still needs its method name.

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
variant-null Variants whose options carry no data, like variant { Active; Paused }
result Special case for Ok/Err variants
nullable Optional types
recursive Self-referential types; the value is in inner
func Function references (canister ID + method)
func-record A record holding a callback (func reference) and its arguments
blob Not produced; blobs resolve as string (below)
unknown Types with no display form, such as reserved

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:

import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
async function resolveTransfer() {
await reactor.initialize()
// Raw canister response
const rawResult = { Ok: 1000000000n } // bigint
// Get metadata (undefined for a method the service does not have)
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:

import { Principal } from "@icp-sdk/core/principal"
// 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"]
// }

A record with exactly one func field, such as an archived_transactions entry returned by a ledger’s get_transactions, resolves as a funcRecord node (displayType: "func-record"). Besides canisterId, methodName and funcClass, it carries defaultArgs: the values beside the callback, arranged as the callback’s arguments. When the callback takes exactly the type of the one other field (ICRC-3’s { args; callback }), that field is the argument.

import { MetadataDisplayReactor } from "@ic-reactor/candid"
// `archived` is the resolved funcRecord node
const archive = new MetadataDisplayReactor({
name: "archive",
canisterId: archived.canisterId,
clientManager,
funcClass: { methodName: archived.methodName, func: archived.funcClass },
})
await archive.callMethod({
functionName: archived.methodName,
args: archived.defaultArgs, // [{ start: "0", length: "100" }]
})

defaultArgs are in the type space of the reactor that resolved the record, which is the one its callMethod takes: display types on MetadataDisplayReactor, and Candid values on MetadataReactor ([{ start: 0n, length: 100n }], with a Principal for a principal and [] or [x] for an opt). Build the callback’s reactor with the same class.

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", ...]
import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
async function describeTransferForm() {
await reactor.initialize()
// Get input metadata for a method
const inputMeta = reactor.getInputMeta("icrc1_transfer")
if (!inputMeta) throw new Error("icrc1_transfer is not in the service")
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.
}
}
}
}

DynamicFieldInput is the field renderer of the next section. The metadata exists once the reactor has initialized, so load it before the form renders, in a route loader or as below. The form is created with a hook, so it lives in a component that takes the metadata as a prop.

import { MetadataDisplayReactor } from "@ic-reactor/candid"
import { useForm } from "@tanstack/react-form"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
// Get metadata
async function loadTransferMeta() {
await reactor.initialize()
const inputMeta = reactor.getInputMeta("icrc1_transfer")
if (!inputMeta) throw new Error("icrc1_transfer is not in the service")
return inputMeta
}
type TransferMeta = Awaited<ReturnType<typeof loadTransferMeta>>
function DynamicForm({ inputMeta }: { inputMeta: TransferMeta }) {
const { args, defaults, schema } = inputMeta
// Create form with metadata
const form = useForm({
defaultValues: defaults,
validators: {
onBlur: 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. Argument `i` is at the path `[i]`.
return (
<form
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
{args.map((field, index) => (
<form.Field key={index} name={`[${index}]`}>
{(fieldApi) => (
<DynamicFieldInput field={field} form={form} fieldApi={fieldApi} />
)}
</form.Field>
))}
<button type="submit">Call Method</button>
</form>
)
}

Nested fields are rendered with TanStack Form’s Field component, addressed by the child’s own name: the full form path ([0].to, [0].amount).

import { isFieldType, type FieldNode } from "@ic-reactor/candid"
import { Field, type AnyFieldApi, type AnyFormApi } from "@tanstack/react-form"
function DynamicFieldInput({
field,
form,
fieldApi,
}: {
field: FieldNode
form: AnyFormApi
fieldApi: AnyFieldApi
}) {
// Handle compound types
if (isFieldType(field, "record")) {
return (
<fieldset>
<legend>{field.displayLabel}</legend>
{field.fields.map((childField) => (
<Field key={childField.name} form={form} name={childField.name}>
{(childApi) => (
<DynamicFieldInput
field={childField}
form={form}
fieldApi={childApi}
/>
)}
</Field>
))}
</fieldset>
)
}
if (isFieldType(field, "variant")) {
const selectedKey = field.getSelectedKey(fieldApi.state.value)
const selectedOption = field.getSelectedOption(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 */}
{selectedOption.type !== "null" && (
<Field form={form} name={selectedOption.name}>
{(optionApi) => (
<DynamicFieldInput
field={selectedOption}
form={form}
fieldApi={optionApi}
/>
)}
</Field>
)}
</div>
)
}
if (isFieldType(field, "vector")) {
const items: unknown[] = fieldApi.state.value || []
return (
<div>
<label>{field.displayLabel}</label>
{items.map((_, i) => {
const itemField = field.createItemField(i)
return (
<Field key={itemField.name} form={form} name={itemField.name}>
{(itemApi) => (
<DynamicFieldInput
field={itemField}
form={form}
fieldApi={itemApi}
/>
)}
</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 && (
<Field form={form} name={field.innerField.name}>
{(innerApi) => (
<DynamicFieldInput
field={field.innerField}
form={form}
fieldApi={innerApi}
/>
)}
</Field>
)}
</div>
)
}
// Primitive types. nat, int, nat64 and int64 are text fields.
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)}
/>
{field.format === "timestamp" && field.candidType === "nat64" && (
<small>Timestamp (nanoseconds)</small>
)}
</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)}
/>
</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>
}
import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
async function showBalance() {
await reactor.initialize()
// 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)
}

Switch on a node’s type: it narrows the node to the shape that has fields, items or selectedValue, and unlike displayType it tells a blob from the other strings.

import type { MethodResult, ResultNode } from "@ic-reactor/candid"
function ResultDisplay({ resolved }: { resolved?: MethodResult }) {
if (!resolved) return null
return (
<div>
<h3>
{resolved.functionName} ({resolved.functionType})
</h3>
{resolved.results.map((node, i) => (
<ResultNodeView key={i} node={node} />
))}
</div>
)
}
function ResultNodeView({ node }: { node: ResultNode }) {
switch (node.type) {
case "blob":
// A blob resolves with displayType "string": the value is always a hex
// string, whatever the payload size, so render the byte count instead.
return (
<div>
<strong>{node.label}:</strong>
<code>
{node.length} bytes (hash: {node.hash.slice(0, 16)}...)
</code>
</div>
)
case "text":
case "principal":
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 "record":
return (
<div>
<strong>{node.label}:</strong>
<div style={{ marginLeft: 20 }}>
{Object.entries(node.fields).map(([key, field]) => (
<ResultNodeView key={key} node={field} />
))}
</div>
</div>
)
case "tuple":
case "vector":
return (
<div>
<strong>{node.label}:</strong>
<ul>
{node.items.map((item, i) => (
<li key={i}>
<ResultNodeView node={item} />
</li>
))}
</ul>
</div>
)
case "variant":
// Also a `result` (Ok/Err) and a variant whose options carry no data
return (
<div>
<strong>{node.label}:</strong> <em>{node.selected}</em>
<div style={{ marginLeft: 20 }}>
<ResultNodeView node={node.selectedValue} />
</div>
</div>
)
case "optional":
if (node.value === null) {
return (
<div>
<strong>{node.label}:</strong> <em>not set</em>
</div>
)
}
return <ResultNodeView node={node.value} />
case "func":
return (
<div>
<strong>{node.label}:</strong>
<span>
Callback: {node.canisterId}.{node.methodName}()
</span>
</div>
)
case "recursive":
// A recursive type's value, resolved one level down
return <ResultNodeView node={node.inner} />
default:
// funcRecord and unknown. `raw` is the Candid value, and JSON.stringify
// throws on a bigint unless a replacer converts it.
return (
<div>
<strong>{node.label}:</strong>
<pre>
{JSON.stringify(
node.raw,
(_key, value) =>
typeof value === "bigint" ? value.toString() : value,
2
)}
</pre>
</div>
)
}
}

You can register methods at runtime with Candid signatures:

import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
async function callCustomMethod() {
await reactor.initialize()
// 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:

import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP Ledger
clientManager,
name: "ICPLedger",
})
async function callWithMeta() {
await reactor.initialize()
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((child) => console.log(child.name))
}
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 { MetadataDisplayReactor } from "@ic-reactor/candid"
import { canisterId, 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: ["hello", "42"], // display types: the bigint is a string
})

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,
args,
})
return result
}
return {
methods: methodInfo,
getInputMeta: (name: string) => reactor.getInputMeta(name),
getOutputMeta: (name: string) => reactor.getOutputMeta(name),
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, or undefined
getOutputMeta(name) Get output display metadata for a method, or undefined
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