Skip to content

MetadataDisplayReactor

MetadataDisplayReactor extends CandidDisplayReactor with visitor-based metadata generation for both form inputs and result display. It provides structured field definitions that are framework-agnostic and can be used with any UI library.

This class is ideal for building dynamic form UIs and result viewers for canisters discovered at runtime:

Argument Metadata

Generate form field definitions with types, labels, paths, and default values

Result Metadata

Generate display field definitions with type info and display transformations

Framework Agnostic

Works with TanStack Form, React Hook Form, or any UI library

Display Transformations

All DisplayReactor transformations (bigint → string, Principal → string)

import { MetadataDisplayReactor } from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
name: "my-dynamic-actor",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.initialize()
// Get form field metadata
const argMeta = reactor.getArgumentMeta("icrc1_transfer")
// argMeta.fields = [{ type: "record", label: "__arg0", fields: [...], defaultValues: {...} }]
// Get result display metadata
const resultMeta = reactor.getResultMeta("icrc1_transfer")
// resultMeta.resultFields[0] = { type: "variant", displayType: "result", isResultType: true, ... }
// Call with transformed results wrapped in metadata
const result = await reactor.callMethod({
functionName: "icrc1_transfer",
args: [{ to: { owner: "aaaaa-aa" }, amount: "1000000" }],
})
/**
* result = {
* functionName: "icrc1_transfer",
* results: [
* {
* field: { type: "variant", displayType: "result", ... },
* value: { option: "Ok", value: { ... } },
* raw: { Ok: 1000000n }
* }
* ],
* ...
* }
*/
import { MetadataDisplayReactor } from "@ic-reactor/candid"
new MetadataDisplayReactor(config: CandidDisplayReactorParameters)
ParameterTypeRequiredDescription
clientManagerClientManagerYesClient manager from @ic-reactor/core
namestringYesRequired for logging & environment lookup
canisterIdCanisterIdNoOptional if defined in environment
candidstringNoCandid service definition (avoids network fetch)

The reactor uses the visitor pattern to generate metadata from Candid types:

┌─────────────────────────────────────────────────────────────────────────────┐
│ MetadataDisplayReactor │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INITIALIZATION (once, from raw IDL types): │
│ │
│ IDL.Service ──► ArgumentFieldVisitor ──► ServiceArgumentsMeta │
│ { methodName: { │
│ fields: ArgumentField[], │
│ defaultValues: [...] │
│ }} │
│ │
│ IDL.Service ──► ResultFieldVisitor ──► ServiceResultMeta │
│ { methodName: { │
│ resultFields: ResultField[], │
│ returnCount: number │
│ }} │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ RUNTIME (per method call): │
│ │
│ INPUT: Display Values ──► encode ──► Candid Values ──► Canister │
│ OUTPUT: Canister ──► Candid Values ──► decode ──► Display Values │
│ │
│ RENDERING: ResultField (metadata) + Display Value ──► UI Component │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Get form field metadata for a method’s arguments.

const meta = reactor.getArgumentMeta("icrc1_transfer")
interface MethodArgumentsMeta {
functionType: "query" | "update"
functionName: string
fields: ArgumentField[] // Form field definitions
defaultValues: unknown[] // Initial form values
}

Each field includes type, label, and path for form binding:

Field TypeProperties
recordfields: ArgumentField[], defaultValues: {...}
variantfields: ArgumentField[], options: string[]
tuplefields: ArgumentField[], defaultValues: [...]
optionalinnerField: ArgumentField, defaultValue: null
vectoritemField: ArgumentField, defaultValue: []
blobitemField: ArgumentField, defaultValue: ""
recursiveextract: () => ArgumentField
principaldefaultValue: "", minLength, maxLength
numberdefaultValue: "", candidType: "nat" | "int" | ...
textdefaultValue: ""
booleandefaultValue: false
nulldefaultValue: null
const meta = reactor.getArgumentMeta("icrc1_transfer")
// meta.fields[0] is a record with nested fields
const transferArg = meta.fields[0] as RecordArgumentField
console.log(transferArg.fields.map((f) => f.label))
// ["to", "amount", "fee", "memo", "created_at_time"]
// Use defaultValues to initialize your form
const initialFormState = meta.defaultValues[0]
// { to: { owner: "", subaccount: null }, amount: "", fee: null, ... }

Get display metadata for a method’s return values.

const meta = reactor.getResultMeta("icrc1_transfer")
interface MethodResultMeta {
functionType: "query" | "update"
functionName: string
resultFields: ResultField[] // Display field definitions
returnCount: number
}

The displayType property tells you what the value will be after transformation:

Candid TypedisplayTypeNotes
nat, int"string"BigInt → string
nat64, int64"string"BigInt → string
nat8 - nat32"number"Stays as JS number
int8 - int32"number"Stays as JS number
float32, float64"number"Stays as JS number
principal"string"Principal.toText()
text"string"No transformation
bool"boolean"No transformation
null"null"No transformation
opt T"nullable"[value]value, []null
vec nat8 (blob)"string"→ hex string
vec T"array"Transformed array
record {...}"object"Transformed object
tuple (...)"array"Transformed array
variant { Ok, Err }"result"Result type detected
variant {...}"variant"Regular variant

The visitor automatically detects Result variants (Ok/Err pattern):

const meta = reactor.getResultMeta("icrc1_transfer")
const resultField = meta.resultFields[0] as VariantResultField
if (resultField.displayType === "result") {
console.log("This is a Result type!")
// Access Ok and Err field definitions
const okField = resultField.optionFields.find((f) => f.label === "Ok")
const errField = resultField.optionFields.find((f) => f.label === "Err")
}

The visitor detects special formats from field labels:

Number Formats:

Label PatternFormat
*timestamp*, *created_at*, *date*timestamp
*cycle*, *cycles*cycle
(default)normal

Text Formats:

Label PatternFormat
*email*email
*url*, *link*url
*phone*phone
*uuid*uuid
*btc*, *bitcoin*btc
*eth*, *ethereum*eth
*account_id*account-id
*canister*, *principal*principal

Get metadata for all methods at once:

const allArgMeta = reactor.getAllArgumentMeta()
// Access any method's metadata
const transferMeta = allArgMeta["icrc1_transfer"]
const balanceMeta = allArgMeta["icrc1_balance_of"]

Get result metadata for all methods:

const allResultMeta = reactor.getAllResultMeta()
// Access any method's result metadata
const transferResultMeta = allResultMeta["icrc1_transfer"]

All methods from CandidDisplayReactor are available:

await reactor.registerMethod({
functionName: "custom_method",
candid: "(text, nat) -> (variant { Ok : nat; Err : text })",
})
// Metadata is generated and display codecs are initialized
const meta = reactor.getArgumentMeta("custom_method")
const resultMeta = reactor.getResultMeta("custom_method")
// Now all Reactor methods work with display types + metadata wrapping
const result = await reactor.callMethod({
functionName: "custom_method",
args: ["hello", "100"],
})

Call a method (registering it if needed) and get both the top-level metadata and the wrapped result in one step.

const { result, meta } = await reactor.callDynamicWithMeta({
functionName: "icrc1_fee",
candid: "() -> (nat) query",
})
// result = { functionName: "icrc1_fee", results: [...] }
// meta = { resultFields: [...], returnCount: 1 }

Perform a dynamic query call. Returns ResolvedMethodResult.

const result = await reactor.queryDynamic({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
args: [{ owner: "aaaaa-aa" }],
})

Perform a dynamic query call with TanStack Query caching. Returns ResolvedMethodResult.

const result = await reactor.fetchQueryDynamic({
functionName: "icrc1_balance_of",
candid: "(record { owner : principal }) -> (nat) query",
args: [{ owner: "aaaaa-aa" }],
})

import { MetadataDisplayReactor } from "@ic-reactor/candid"
import type {
RecordArgumentField,
NumberArgumentField,
OptionalArgumentField,
} from "@ic-reactor/candid"
const reactor = new MetadataDisplayReactor({
name: "transfer-form",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
candid: ICRC1_CANDID,
clientManager,
})
await reactor.initialize()
// Get transfer form metadata
const meta = reactor.getArgumentMeta("icrc1_transfer")
function buildForm() {
const transferArg = meta.fields[0] as RecordArgumentField
return transferArg.fields.map((field) => {
switch (field.type) {
case "number":
return {
type: "input",
inputType: "text",
label: field.label,
placeholder: `Enter ${(field as NumberArgumentField).candidType}`,
defaultValue: "",
}
case "optional":
return {
type: "optional-input",
label: field.label,
innerType: (field as OptionalArgumentField).innerField.type,
defaultValue: null,
}
case "record":
return {
type: "nested-form",
label: field.label,
fields: (field as RecordArgumentField).fields,
}
// ... handle other types
}
})
}
const meta = reactor.getResultMeta("icrc1_transfer")
const result = await reactor.callMethod({
functionName: "icrc1_transfer",
args: [transferArgs],
})
function renderResult(field: ResultField, value: unknown) {
switch (field.type) {
case "number":
const numField = field as NumberResultField
if (numField.numberFormat === "timestamp") {
return new Date(Number(value) / 1_000_000).toLocaleString()
}
return value // Already a string for nat/int
case "variant":
const variantField = field as VariantResultField
if (variantField.isResultType) {
// Result type - value is already unwrapped by DisplayReactor
return renderSuccess(value)
}
return renderVariant(value, variantField)
case "record":
return renderRecord(value, field as RecordResultField)
default:
return String(value)
}
}
import { useForm } from "@tanstack/react-form"
import { MetadataDisplayReactor } from "@ic-reactor/candid"
import type { RecordArgumentField } from "@ic-reactor/candid"
function TransferForm({ reactor }: { reactor: MetadataDisplayReactor }) {
const meta = reactor.getArgumentMeta("icrc1_transfer")
const transferArg = meta.fields[0] as RecordArgumentField
const form = useForm({
defaultValues: meta.defaultValues[0],
onSubmit: async ({ value }) => {
const result = await reactor.callMethod({
functionName: "icrc1_transfer" as any,
args: [value],
})
console.log("Transfer result:", result)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
{transferArg.fields.map((field) => (
<DynamicField key={field.label} field={field} form={form} />
))}
<button type="submit">Transfer</button>
</form>
)
}
function DynamicField({ field, form }) {
switch (field.type) {
case "number":
return (
<form.Field name={field.label}>
{(fieldApi) => (
<input
type="text"
value={fieldApi.state.value}
onChange={(e) => fieldApi.handleChange(e.target.value)}
placeholder={field.candidType}
/>
)}
</form.Field>
)
// ... other types
}
}

Use CaseRecommended Class
Dynamic form generationMetadataDisplayReactor
Dynamic result rendering with typesMetadataDisplayReactor
Dynamic canister, display types onlyCandidDisplayReactor
Dynamic canister, raw typesCandidReactor
Known canister, display typesDisplayReactor
Known canister, raw typesReactor