Skip to content
IC Reactor

MetadataReactor

MetadataReactor is the candid-native metadata reactor. It extends CandidReactor and adds runtime metadata for:

  • argument forms (getInputMeta, getAllInputMeta)
  • result rendering (getOutputMeta, getAllOutputMeta)
  • hydrated initial form values from candid args hex (buildForMethod)
  • dynamic variable mapping candidates (buildMethodVariableCandidates)

Unlike MetadataDisplayReactor, this class keeps argument handling candid-native.

import { MetadataReactor } from "@ic-reactor/candid"

Use MetadataReactor when you want:

  • dynamic forms for unknown canister methods
  • zod schemas + defaults generated from Candid at runtime
  • metadata-driven output rendering
  • candid-first semantics for arguments and method registration

Use MetadataDisplayReactor if you specifically want display-transform behavior for arguments in your app flow.

Form Schema Generation

Generate field trees, defaults, component hints, and zod schemas directly from Candid.

Output Resolution

Resolve canister return values with method-aware output metadata.

Hydration from Hex

Convert candid-encoded argument hex into UI-friendly form values.

Runtime Method Expansion

Register new methods at runtime and regenerate metadata immediately.

import { MetadataReactor } from "@ic-reactor/candid"
import { ClientManager } from "@ic-reactor/core"
import { QueryClient } from "@tanstack/query-core"
const clientManager = new ClientManager({ queryClient: new QueryClient() })
await clientManager.initialize()
export const reactor = new MetadataReactor({
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
clientManager,
})
await reactor.initialize()
const method = "icrc1_transfer"
const inputMeta = reactor.getInputMeta(method)
const outputMeta = reactor.getOutputMeta(method)

getInputMeta(methodName) returns FormArgumentsMeta for a single method.

  • args: structured field nodes
  • defaults: default form state
  • schema: zod tuple for method args
  • argCount, isEmpty, functionType

getAllInputMeta() returns the method-to-metadata map for the full service.

import { reactor } from "./reactor"
const transferInput = reactor.getInputMeta("icrc1_transfer")
console.log(transferInput?.defaults)
console.log(transferInput?.schema)

getOutputMeta(methodName) returns MethodMeta, including a resolve(...) function.

getAllOutputMeta() returns metadata for all methods.

callMethod already resolves the reply through this metadata, so it returns a MethodResult, not the raw Candid value. The raw value is in its raw field, and that is what resolve(...) takes.

import { reactor } from "./reactor"
const transferOutput = reactor.getOutputMeta("icrc1_transfer")
const result = await reactor.callMethod({
functionName: "icrc1_transfer",
args: [/* candid args */],
})
console.log(result.results) // resolved nodes, one per return value
// resolve() takes the raw Candid value, such as result.raw
const resolved = transferOutput?.resolve(result.raw)

buildForMethod(methodName, { candidArgsHex }) combines:

  • metadata generation
  • hydration decoding for initial form values

Hydration status can be:

  • empty
  • hydrated
  • skipped
  • error
import { reactor } from "./reactor"
// icrc1_balance_of's argument, { owner = "aaaaa-aa"; subaccount = null },
// Candid-encoded
const built = await reactor.buildForMethod("icrc1_balance_of", {
candidArgsHex: "4449444c036d7b6e006c02b3b0dac30368ad86ca8305010102010000",
})
console.log(built.meta.defaults) // [{ owner: "", subaccount: null }]
console.log(built.hydration.status) // "hydrated"
if (built.hydration.status === "hydrated") {
console.log(built.hydration.values) // [{ owner: "aaaaa-aa", subaccount: null }]
}

You can skip hydration if payloads include unsupported placeholders:

import { reactor } from "./reactor"
const built = await reactor.buildForMethod("icrc1_transfer", {
candidArgsHex,
skipHydrationIfContains: "{{",
})

Build Metadata for a Standalone Value Type

Section titled “Build Metadata for a Standalone Value Type”

Use buildForValueType(...) when you want field/schema metadata for one type expression.

import { reactor } from "./reactor"
const valueMeta = await reactor.buildForValueType(
"record { owner : principal; amount : nat }"
)
console.log(valueMeta.meta.defaults)

buildMethodVariableCandidates(methodName) helps build mapping UIs for workflow tools. It lists the paths into the method’s return value that a later step can reference. Arguments are not included.

import { reactor } from "./reactor"
const candidates = reactor.buildMethodVariableCandidates("icrc1_transfer")
// e.g. "$icrc1_transfer.Ok", "$icrc1_transfer.Err.InsufficientFunds.balance"

When methods are discovered at runtime, register them and metadata is regenerated automatically.

import { reactor } from "./reactor"
await reactor.registerMethod({
functionName: "get_metadata",
candid: "() -> (record { name : text; version : nat }) query",
})
const meta = reactor.getInputMeta("get_metadata")
  1. Create and initialize MetadataReactor.
  2. Read method metadata with getInputMeta / getOutputMeta.
  3. Build UI state from defaults + schema.
  4. Optionally hydrate from candidArgsHex for replay/edit flows.
  5. Call the method and resolve output through MethodMeta.resolve.

Practical Pattern: Dynamic Form + Result Viewer

Section titled “Practical Pattern: Dynamic Form + Result Viewer”

The form metadata holds display types: text for principals and numbers, null for an empty opt, { _type } for a variant. MetadataReactor is candid-native, so the values the user submits are converted to Candid before callMethod.

import { didToDisplayCodec } from "@ic-reactor/core"
import { IDL } from "@icp-sdk/core/candid"
import { reactor } from "./reactor"
const methodName = "icrc1_transfer"
const inputMeta = reactor.getInputMeta(methodName)
if (!inputMeta) throw new Error("Method metadata missing")
// 1) initialize the form with inputMeta.defaults: "" for principals, text
// and numbers. "" is a valid text, so a text field left empty is sent
// as ""; principals and numbers fail the schema until the user fills
// them in. Say the user entered:
const formValues = [
{
to: { owner: "aaaaa-aa", subaccount: null },
amount: "100000",
fee: null,
memo: null,
from_subaccount: null,
created_at_time: null,
},
]
// 2) validate before submit
const values = inputMeta.schema.parse(formValues)
// 3) convert the display types to Candid (Principal, bigint, [] or [x])
const func = reactor.getServiceInterface().fieldsAsObject()[methodName]
const args = didToDisplayCodec(IDL.Tuple(...func.argTypes)).asCandid(values)
const result = await reactor.callMethod({ functionName: methodName, args })
// 4) render: callMethod returns the resolved result, one node per value
const rendered = result.results

MetadataDisplayReactor takes display types in callMethod, so it sends the form values without step 3.

A record with exactly one func field, such as an archived block range in a ledger’s reply, resolves as a funcRecord node. Its defaultArgs are the values beside the callback, arranged as the callback’s arguments. On MetadataReactor they are Candid values (bigints, a Principal, [] or [x] for an opt), which is what its callMethod takes, so a reactor built from the node’s funcClass can call the callback with them as they are:

import { MetadataReactor } from "@ic-reactor/candid"
// `archived` is the resolved funcRecord node, for example
// result.results[0].fields.archived_blocks.items[0]
console.log(archived.defaultArgs) // [{ start: 0n, length: 100n }]
const archive = new MetadataReactor({
name: "archive",
canisterId: archived.canisterId,
clientManager,
funcClass: { methodName: archived.methodName, func: archived.funcClass },
})
const blocks = await archive.callMethod({
functionName: archived.methodName,
args: archived.defaultArgs,
})

MetadataDisplayReactor gives display-typed defaultArgs ("0" for that nat64) for its own display-typed callMethod. Call the callback through the same kind of reactor that resolved the record.

  • Ensure await reactor.initialize() completed.
  • Confirm method exists in reactor.getMethodNames().
  • If method is dynamic, call registerMethod(...) first.
  • Confirm candidArgsHex matches the method argument types exactly.
  • Ensure hex is plain hex (no 0x prefix).
  • You passed skipHydrationIfContains and input matched that token.
  • Remove token or skip-option if full decode is expected.