Initialize Once
Call initialize() once and reuse the reactor. Metadata generation happens
at initialization.
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:
DisplayReactor (from @ic-reactor/core) └── CandidDisplayReactor (display-reactor.ts) └── MetadataDisplayReactor (metadata-display-reactor.ts)bigint → string, Principal → string for easy UI handlingArgumentsMeta)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}FieldNode)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}interface VariantField { type: "variant" options: FieldNode[] // Option fields defaultOption: string // First option key defaultValue: Record<string, unknown> // Selected option value getOptionDefault(option: string): Record<string, unknown> getOption(option: string): FieldNode getSelectedKey(value): string getSelectedOption(value): FieldNode}interface VectorField { type: "vector" itemField: FieldNode // Template for items defaultValue: unknown[] // Empty array getItemDefault(): unknown // New item default createItemField(index: number): FieldNode // Create indexed field}interface OptionalField { type: "optional" innerField: FieldNode // Inner type field defaultValue: null // Always null getInnerDefault(): unknown // Get inner default when enabling isEnabled(value): boolean // Check if value is set}interface NumberField { type: "number" unsigned: boolean // nat vs int isFloat: boolean // float32/64 bits?: number // 8, 16, 32, 64 min?: string // Min value as string max?: string // Max value as string format: NumberFormat // "timestamp" | "cycle" | "normal" inputProps: PrimitiveInputProps}interface TextField { type: "text" minLength?: number maxLength?: number multiline?: boolean format: TextFormat // "email" | "url" | "phone" | "plain" | etc. inputProps: PrimitiveInputProps}MethodMeta)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}ResultNode)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 |
blob | Binary data with hash |
func | Function references (canister ID + method) |
The resolve() method transforms raw canister responses into display-ready structures:
// Raw canister responseconst rawResult = { Ok: 1000000000n } // bigint
// Get metadataconst meta = reactor.getOutputMeta("icrc1_transfer")
// Resolve to display typesconst 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 referenceconst 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 |
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 managerconst clientManager = new ClientManager({queryClient: new QueryClient(),withProcessEnv: true,})
await clientManager.initialize()
// 2. Create and initialize reactor in one stepconst reactor = await createMetadataReactor({canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai", // ICP LedgerclientManager,name: "ICPLedger",})
// 3. Get available methodsconst methods = reactor.getMethodNames()console.log(methods) // \["icrc1_balance_of", "icrc1_transfer", ...\]import { ClientManager } from "@ic-reactor/core"import { MetadataDisplayReactor } from "@ic-reactor/candid"import { QueryClient } from "@tanstack/query-core"
// 1. Create client managerconst clientManager = new ClientManager({ queryClient: new QueryClient(), withProcessEnv: true,})
await clientManager.initialize()
// 2. Create reactorconst 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 methodsconst methods = reactor.getMethodNames()console.log(methods) // \["icrc1_balance_of", "icrc1_transfer", ...\]// Get input metadata for a methodconst inputMeta = reactor.getInputMeta("icrc1_transfer")
console.log(inputMeta.functionName) // "icrc1_transfer"console.log(inputMeta.functionType) // "update"console.log(inputMeta.argCount) // 1console.log(inputMeta.defaults) // [{ to: { owner: "", subaccount: null }, ... }]
// Iterate over args to build formfor (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 metadataconst inputMeta = reactor.getInputMeta("icrc1_transfer")
// Create form with metadataconst 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 dynamicallyfunction 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 resultconst result = await reactor.callMethod({ functionName: "icrc1_balance_of", args: [{ owner: "aaaaa-aa" }],})
// result is already resolved through transformResultconsole.log(result)// {// functionType: "query",// functionName: "icrc1_balance_of",// results: [{ type: "number", value: "1000000000", displayType: "string", raw: 1000000000n }],// raw: 1000000000n// }
// Or get metadata and resolve manuallyconst 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 serviceawait reactor.registerMethod({ functionName: "custom_method", candid: "(record { input: text }) -> (record { output: text }) query",})
// Metadata is automatically regeneratedconst argMeta = reactor.getInputMeta("custom_method")const resultMeta = reactor.getOutputMeta("custom_method")
// Call the newly registered methodconst 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 resolvedThe library provides utility functions for working with fields:
import { isFieldType, isCompoundField, isPrimitiveField, hasChildFields, hasOptions, formatLabel} from "@ic-reactor/candid"
// Type guardsif (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 formattingformatLabel("__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-checkedconst meta = reactor.getInputMeta("my_method") // TypeScript knows the method names
// Return types are inferredconst result = await reactor.callMethod({ functionName: "my_method", args: [...]})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, }}
// Usageconst explorer = await createCanisterExplorer("ryjl3-tyaaa-aaaaa-aaaba-cai")
console.log("Available methods:", explorer.methods)
// Get form metadata for icrc1_balance_ofconst 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 |