Skip to content

Host Functions

Reactive expressions are deliberately a small language — paths, operators, ternaries. That is enough for $form.total > 100, but not for currency formatting or a tax calculation. Host functions close that gap: you pass a map of plain functions to the form, and expressions call them through the $fn namespace.

Register the function in your application code:

const functions = {
grandTotal: (items = []) =>
items.reduce((total, item) => total + (item.price ?? 0), 0),
};

Then call it from any expression in the form definition:

{ "text": "Grand total: {{ $fn.grandTotal($form.lineItems) }}" }

The form definition declares which value to show — your typed, testable application code decides how to compute it.

A host function is any function that derives a result from its arguments — the key in the map is the name expressions use after $fn.:

import type {
ExpressionFunction,
ExpressionFunctions,
} from '@golemui/gui-shared';
// type ExpressionFunction = (...args: any[]) => unknown;
// type ExpressionFunctions = Record<string, ExpressionFunction>;
const functions: ExpressionFunctions = {
lineTotal: (item) => (item?.quantity ?? 0) * (item?.unitPrice ?? 0),
formatMoney: (amount, currency) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency ?? 'USD',
}).format(amount),
hasItems: (items) => (items?.length ?? 0) > 0,
};

Three rules govern what you can put in that map:

  • Pure. Derive the result only from the arguments — no mutation of the values you receive, no network calls, no writes to outside state. Arguments arrive as live references into form data, so a function that mutates them corrupts the store.
  • Synchronous. Expressions are evaluated inside the reducer and nothing is awaited. An async function returns a Promise, which renders as [object Promise] in a text slot and is never true in a condition — fetch the data in your application and pass it in through meta instead.
  • Cheap. Expressions re-evaluate on every state change, and expressions inside a repeater run once per row. There is no memoization — keep the body to arithmetic, formatting, and array reduction.

In the Programmatic API, functions go inside formConfig.functions.

import { gui } from '@golemui/gui-shared';
import { GuiForm } from '@golemui/gui-react';
const functions = {
grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0),
};
const config = {
formDef: [
gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }),
],
formConfig: { functions },
};
export function MyForm() {
return <GuiForm config={config} />;
}

Functions cannot live in a JSON form definition, so they go into the functions property of the config object, as a sibling of formDef.

{
"form": [
{
"uid": "total",
"kind": "display",
"type": "alert",
"props": { "text": "Total: {{$fn.grandTotal($form.items)}}" }
}
]
}
import { GuiForm } from '@golemui/gui-react';
import formDef from './my-form.json';
const config = {
formDef,
functions: {
grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0),
},
};
export function MyForm() {
return <GuiForm config={config} />;
}

The syntax is $fn.functionName(arg1, arg2). $fn is available everywhere the expression language is:

ContextExample
String interpolation slots"Total: {{$fn.grandTotal($form.items)}}"
i18n dynamic paramsparams: { total: '$fn.grandTotal($form.items)' }
Inline when on include, exclude, disabled, readonlyinclude: { when: '$fn.hasItems($form.items)' }
Named state expressionsstates: { hasItems: '$fn.hasItems($form.items)' }

Arguments are ordinary expressions, so you can pass form paths, whole scope objects, literals, and the results of other host functions:

{
"text": "{{ $fn.formatMoney($fn.grandTotal($form.lineItems), $form.currency) }}"
}

Nothing is passed implicitly — a function receives exactly the arguments the expression lists, and a zero-argument call such as {{$fn.isWeekendPromo()}} is valid.

Widgets in a repeater’s props.template also see $item and $index, and both are valid arguments. The expression is evaluated once per row — each row gets its own result:

{
"kind": "display",
"type": "markdown-text",
"props": {
"md": "**Line {{$index + 1}}** \n{{ $fn.formatMoney($fn.lineTotal($item), $form.currency) }}"
}
}

The same applies to conditions — include: { when: '$fn.isEven($item.quantity)' } hides and shows individual rows independently.

Note that named states declared in formConfig.states are evaluated at form level, so they receive $fn but never $item or $index. Per-row logic belongs in an inline when. See Repeater - $item and $index.

A host function that throws, and a call to a name you never registered, fail the same way — at evaluation time. What happens next depends on where the expression lives.

ContextBehavior when the call fails
Text slots and i18n paramsThe form reports { status: 'errored', code: 40 } through formHealth and stops re-rendering.
Named state expressionsThe state is treated as inactive. formHealth stays ok.
include / exclude / disabled / readonly whenThe error propagates out of dispatch to your application code.

The asymmetry is deliberate — a broken text slot is a definition bug you want surfaced loudly, while a state expression that cannot be evaluated simply is not active. Because the third case surfaces as an uncaught exception, guard your arguments rather than relying on a catch.

Referencing a function without calling it does not throw. $fn.missing evaluates to undefined, so {{$fn.formatMoney ? 'yes' : 'no'}} is a safe feature check — but $fn.missing() is a TypeError.

  • The map is read once, at initialization. It is captured when the form store is created — replacing functions on an already-mounted form has no effect, so re-initialize the form instead.
  • Names are not validated ahead of time. The MCP server and the JSON schema have no knowledge of which functions your host supplies, so any $fn.anything(...) passes linting and only fails at runtime — keep the names in the form definition and the map in sync.
  • functions is host config, not form data. It is never part of the serialized form definition, so it does not appear in form.schema.json and is not carried by a form you save or send over the wire.
  • Runtime functions do not receive $fn. Their callback already runs in your application code, so call the function directly.
  • String Interpolation — the {{...}} slots where $fn is most often used.
  • States — named conditions and inline when expressions.
  • Dependencies — the sibling config entry for injecting third-party helpers into widgets.
  • Runtime Functions — the Programmatic alternative when you need a full callback instead of an expression.