24. Web UI Server Components
MALDA includes native support for server-rendered UI using components, fragments, live updates, and the ui.* runtime.
ui.mount / ui.render, and do not treat a @PAGE return value as a ui.* tree (IDE UI1002).
- HTML fragments (24.2):
component,@ACTION,@LIVE,componentFragment— form/list pages that return HTML and optional SSE. - Server-driven trees (24.3):
ui.button(props, children?, key?),ui.mount/ui.render— incremental patches hosted by UIHost.
24.1 Chapter Map
- 24.2 HTML fragment components and migration from
@PAGE - 24.3 Web UI helper API (
component*andui.*) - 24.3.3 Runtime, lifecycle, event loop, and how a
ui.*session is hosted - 24.3.4-24.3.7 Control catalog, styling, data controls, and props reference
- 24.3.8-24.3.15 Events, validation, composition, state, routing, security, styling, pitfalls
- 24.4-24.6 Request lifecycle, production hardening, and troubleshooting
- 24.7 Related chapters and runnable examples
24.2 HTML Fragment Components
This section is the HTML fragment model: the server returns HTML strings (full page or a targeted fragment). It is additive and backward-compatible with existing @PAGE/@AIPAGE routes. For node trees and ui.mount, skip to 24.3.
Mental model: render on the server, mutate via action endpoints, optionally push updates through a live channel.
component Name(...) { ... }- component entry function (syntax sugar for@COMPONENT())@ACTION(path)- POST action for form/button interactions (typically returns a fragment)@LIVE(path)- live endpoint for SSE clients (return{"sse": true})componentStateGet/Set/Object/Clear/Configure/Pin/Unpin- server-side state helpers per component key (pin = process-lifetime, exempt from TTL/LRU)componentFragment(targetId, html)- tells the client which DOM target should be replacedcomponentLiveEmit(channel, payload, eventType?)- broadcasts live events to connected SSE clientsonAgentProgress(handlerOrChannel)- subscribe to agent-loop events (round_start,tool_calls,tool_done,continue,done). Pass a functionhandler(event)(interpreter) or a live channel string so progress is published withcomponentLiveEmit(transpile-friendly)clearAgentProgress()- remove the current agent-progress handler or live channel
24.2.1 Minimal server-side example
component ItemBoard() {
var items = componentStateGet("board", "items", []);
var list = "";
var i = 0;
while (i < items.length) {
list = list + "<li>" + items[i].title + "</li>";
i = i + 1;
}
return "<h1>Items</h1><div id='item-list'><ul>" + list + "</ul></div>";
}
@ACTION("/items/add")
function addItem(body) {
var items = componentStateGet("board", "items", []);
items.append({"title": body.title, "status": "open"});
componentStateSet("board", "items", items);
componentLiveEmit("items", {"count": items.length}, "item-added");
return componentFragment("item-list", "<ul><li>" + body.title + "</li></ul>");
}
@LIVE("/items/live")
function itemsLive() {
return {"sse": true};
}
24.2.2 Tiny browser integration example
<form id="item-form">
<input name="title" />
<button type="submit">Add</button>
</form>
<div id="item-list"></div>
<script>
document.getElementById("item-form").addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const r = await fetch("/items/add", { method: "POST", body: fd });
const html = await r.text();
document.getElementById("item-list").innerHTML = html;
});
const es = new EventSource("/items/live");
es.onmessage = (evt) => console.log("live update", evt.data);
</script>
24.2.3 Fragment contract
When an @ACTION returns componentFragment(targetId, html), the HTTP response includes:
X-Malda-Fragment: trueX-Malda-Fragment-Target: <targetId>- Body containing the fragment HTML
The built-in AJAX helper checks these headers and updates only the target element. If headers are missing, it falls back to full-page replacement.
24.2.4 LIVE channel subscription
Use query parameter ?channel=... on LIVE endpoints to scope events:
// Client subscribes only to item events
var es = new EventSource("/items/live?channel=items");
// Server emits only to channel "items"
componentLiveEmit("items", {"count": 8}, "item-added");
Multiple channels can be subscribed as comma-separated values, for example ?channel=items,alerts.
24.2.5 Server UI protocol envelopes
The server UI host uses versioned envelopes for mount/patch/event transport. Important fields include:
version- protocol version string.sequence- monotonic sequence number for ordering.envelopeId- id used for ack/nack correlation.patches- incremental patch operations (ReplaceNode,SetProp,RemoveProp,InsertChild,RemoveChild).
Clients should acknowledge processed envelopes and request resync when sequence gaps are detected.
UiNode, diff, sessions), host endpoints, and env vars are documented in docs/ui-framework.md. This chapter stays focused on the language API.
24.2.6 Component API V2 direction
The server UI framework now includes a broader component set for forms, navigation, data, and feedback. Prefer the ui.* API in this chapter for component-centric server UI flows, and keep @PAGE/@AIPAGE for route-first HTML generation patterns.
Advanced controls include ui.dataGrid (sorting/filtering/pagination, row selection, viewport virtualization events, drag/drop hooks) and ui.treeView (hierarchical nodes, expand/collapse, keyboard-friendly selection, optional lazy loading). In both controls, keep business state server-side and use client-side transient state for scroll/viewport responsiveness.
24.2.7 Migration guide: @PAGE to component
- Keep your existing
@PAGEroute as-is for compatibility. - Move reusable rendering logic into a new
componentfunction. - Add
@ACTIONendpoints for form and button interactions. - Use
componentFragment(...)to return only the affected HTML block. - Add
@LIVE+componentLiveEmit(...)if the page needs push updates.
24.3 Web UI Helper API Reference
This section is the language API for both fragment helpers (componentFragment, templates, CRUD HTML) and server-driven ui.* trees (ui.mount, controls, event loop). Shared state helpers (componentState* / ui.state) apply to both.
24.3.1 Server component helper functions
renderTemplate(template, model?)- Token replacement using{{key}}placeholders.componentFragment(targetId, html)- Returns a standardized fragment envelope for partial DOM updates.componentLiveEmit(channel, payload, eventType?)- Broadcasts a live event to SSE subscribers.
var html = renderTemplate(
"<h1>{{title}}</h1><p>{{count}} items</p>",
{"title": "Items", "count": 4}
);
@ACTION("/items/add")
function addItem(body) {
return componentFragment("item-list", "<ul><li>" + body.title + "</li></ul>");
}
componentLiveEmit("items", {"count": 7}, "item-added");
24.3.1.1 UI-native template workflow (transparent UX)
For larger apps, use the UI template helpers to keep HTML in external files and MALDA logic in code:
ui.template(source, model?, options?)- render a template file (or inline source) with interpolation and block helpers.ui.partial(source, model?)- render a fragment/partial template.ui.layout(source, slots, model?)- render layout + named slots with{{slot:name}}placeholders.ui.renderList(items, source, itemName?)- render repeated items with a shared item template.
var base = "templates/";
var sidebar = ui.template(base + "sidebar.html", {
"overviewClass": "nav-link active",
"itemsClass": "nav-link"
});
var rowsHtml = ui.renderList(items, base + "item_row.html", "item");
var body = ui.partial(base + "items_body.html", {
"rows": rowsHtml
});
return ui.layout(
base + "layout.html",
{
"sidebar": sidebar,
"pageBody": body,
"pageScript": "initItems();"
},
{
"pageTitle": "Items",
"appCssPublicPath": "/static/app.css"
}
);
Template syntax:
{{key}}- escaped interpolation (safe by default){{{key}}}- raw interpolation (use intentionally){{#if condition}}...{{/if}}- conditional rendering{{#each items as item}}...{{/each}}- list rendering with alias
<ul>
{{#each items as item}}
{{#if item.open}}
<li>{{item.title}} - {{{item.badgeHtml}}}</li>
{{/if}}
{{/each}}
</ul>
Compatibility: pass {"compatRaw": true} to ui.template(..., ..., options) to preserve legacy raw behavior for {{key}} during migration.
Caching: template files are cached by default. Pass {"cache": false} as the third argument to ui.template for development-time reload behavior.
Recommended structure: keep template files under a project-local templates/ directory (for example, templates/layout.html) and reference them by path. Runnable Web samples live under Examples/Web/ (for example ui_form_workflow.malda and ui_controls_showcase_minimal.malda).
24.3.1.2 Schema-driven CRUD helper pattern
For CRUD pages that share the same UI skeleton (filters, add/edit dialogs, list controls), use the built-in schema pipeline:
ui.crudModel(schema, sessionId?, queryParams?, lookups?)- builds the controls model consumed by shared CRUD templates.ui.crudControls(schema, sessionId?, queryParams?, lookups?, options?)- builds the model and renders HTML in one step.ui.crudSchema(schema, defaults?)- normalizes schema defaults (template paths, filter arrays, labels) before rendering.
Default behavior: ui.crudControls resolves entity_controls.html under the schema templateBasePath (default templates) when no template override is provided.
- Define a data-only schema per entity: labels, routes, filter descriptors, template paths, lookup requirements.
- Use
ui.crudModelwhen you need direct control over template rendering or composition. - Use
ui.crudControlsfor the common end-to-end path. - Keep entity page handlers focused on: parse query, fetch rows/lookups, call one built-in, render layout.
var schema = itemsCrudSchema; // data only
var sessionId = queryParams["session"] == null || queryParams["session"] == "" ? schema.sessionDefault : queryParams["session"];
var lookups = {"owners": allOwners(), "tags": listTags()};
var controlsHtml = ui.crudControls(schema, sessionId, queryParams, lookups);
var normalized = ui.crudSchema(schema, {
"templateBasePath": "templates",
"controlsTemplatePath": "templates/entity_controls.html"
});
var controlsHtml = ui.crudControls(normalized, sessionId, queryParams, lookups);
Overrides: pass {"templatePath": "...", "templateBasePath": "...", "cache": false} in ui.crudControls(..., options) when you need a different controls template, base path, or cache behavior.
Migration note: if your app currently normalizes CRUD schema fields manually, migrate that logic to ui.crudSchema to keep schema defaults consistent across apps.
24.3.1.3 CRUD schema reference
The object passed to ui.crudSchema, ui.crudModel, and ui.crudControls may contain the following properties. Values are copied into the model used to render the controls template; missing properties get defaults as described.
Top-level schema properties
| Property | Type | Description / default |
|---|---|---|
entityPluralLower | string | Entity name plural, e.g. "items". Passed to the controls template. |
entitySingularLower | string | Entity name singular, e.g. "item". Used by ui.crudSchema to derive default openAddLabel / openEditLabel when omitted (default "item"). |
sessionDefault | string | Default session id when sessionId is not passed to ui.crudControls / ui.crudModel. Default "default". |
templateBasePath | string | Base directory for dialog and lookup option templates. ui.crudSchema default: templates (or from defaults.templateBasePath). |
controlsTemplatePath | string | Path to the entity controls template (filters + buttons + grid host). Default: templateBasePath + "/entity_controls.html". Fallback: controlsTemplate. |
listAction | string | URL or path for the list page, e.g. "/web/items". Passed to the controls template. |
filterGridColumns | string | CSS grid columns for the filter bar, e.g. "2fr 1fr auto". Passed to the controls template. |
openAddButtonId | string | DOM id of the button that opens the add dialog, e.g. "openItemAddDialog". |
openEditButtonId | string | DOM id of the button that opens the edit dialog, e.g. "openItemEditDialog". |
openAddLabel | string | Label for the add action. ui.crudSchema default: "Add " + entitySingularLower (or from defaults.openAddLabel). |
openEditLabel | string | Label for the edit action. ui.crudSchema default: "Edit selected " + entitySingularLower (or from defaults.openEditLabel). |
addDialogTemplate | string | Template filename for the add dialog HTML (resolved under templateBasePath). |
editDialogTemplate | string | Template filename for the edit dialog HTML (resolved under templateBasePath). |
dialogScriptTemplate | string | Template filename for the dialog script (resolved under templateBasePath). |
filterDefs | array | List of filter definitions (see below). ui.crudSchema coerces non-array to []. |
dialogLookupOptions | array | List of lookup bindings for add/edit dialogs (see below). ui.crudSchema coerces non-array to []. Each source must exist in the lookups object passed to ui.crudControls / ui.crudModel or dropdowns will be empty. |
gridDescription | string | Optional. Description for the grid host; used by app layouts for accessibility or layout. |
envelopeApiPath | string | Optional. API path for the UI envelope; used by app layouts that mount the grid via ui.mountEnvelope. |
filterDefs — each element is an object:
| Property | Type | Description |
|---|---|---|
kind | string | "input" (text) or "select" (dropdown). |
name | string | Query parameter name; required. Used to read/write the filter value from queryParams. |
placeholder | string | Placeholder text for kind === "input". |
defaultValue | string | Default value when the query param is missing. |
options | array | For kind === "select". Each item: {"value": "...", "label": "..."}. Rendered as <option>s with selected set from current query. |
dialogLookupOptions — each element is an object that binds one lookup array to a key in the dialog model (e.g. for dropdown options in add/edit forms):
| Property | Type | Description |
|---|---|---|
key | string | Name under which the rendered HTML is set in the dialog model (e.g. "customerOptions"). The add/edit dialog template uses this to inject option HTML. |
source | string | Key in the lookups object passed to ui.crudControls / ui.crudModel (e.g. "customers", "agents"). Required. |
renderer | string | Optional. Built-in hint: "customerOptions" or "agentOptions" supply default templatePath and itemName when templatePath is omitted. |
templatePath | string | Path to the template that renders one option row (resolved with templateBasePath if relative). Required unless renderer is one of the built-ins. |
itemName | string | Name of the loop variable in that template (default "item"). For renderer === "customerOptions" / "agentOptions" the default becomes "customer" / "agent". |
Template resolution: When calling ui.crudControls(schema, ...), the controls template can be overridden per call via options.templatePath. The base path for dialog/lookup templates can be overridden via options.templateBasePath. Schema values templateBasePath and controlsTemplatePath (or controlsTemplate) are used when options do not provide overrides.
24.3.2 Server component state helpers
componentStateGet(componentId, key, defaultValue?, scope?)— peek (default is not written)componentStateSet(componentId, key, value, scope?)componentStateObject(componentId, scope?)componentStateClear(componentId?, scope?)(when omitted, clears all component state)componentStateConfigure(maxComponents, maxKeysPerComponent, ttlMs?)componentStatePin(componentId, scope?)/componentStateUnpin(componentId, scope?)— pin exempts the scoped entry from TTL and LRU eviction
var items = componentStateGet("board", "items", []);
items.append({"title": "Login issue"});
componentStateSet("board", "items", items);
print(componentStateObject("board"));
componentStateClear("board");
// Tenant/session scoping (optional)
componentStateSet("board", "count", 1, "tenantA");
componentStateSet("board", "count", 2, "tenantB");
// Safety policy tuning (defaults are conservative)
componentStateConfigure(512, 128, 1800000);
// Process-lifetime shared data (catalog, boot config): pin after write
componentStateSet("brain", "catalog", catalog, "ask-shared");
componentStatePin("brain", "ask-shared");
24.3.3 Server UI runtime and lifecycle APIs
- Render lifecycle:
ui.mount(rootNode, sessionId?),ui.render(rootNode, sessionId?) - Envelope helper:
ui.mountEnvelope(rootNode, sessionId?, options?)for mount + snapshot + resync in one call. - Lifecycle subscriptions:
ui.onInit(componentId, sessionId?),ui.onPreRender(componentId, sessionId?),ui.onLoad(componentId, sessionId?),ui.onDispose(componentId, sessionId?),ui.onMount(componentId, sessionId?),ui.onUpdate(componentId, sessionId?),ui.onUnmount(componentId, sessionId?),ui.onError(componentId, sessionId?) - Event pipeline:
ui.dispatchEvent(eventObj, sessionId?, sequence?),ui.pullEvent(sessionId?).sessionIdis always the second argument (string);sequenceis the optional third (integer). Do not pass sequence as the second argument. - Correct loop:
pullEvent→ update state → rebuild tree →ui.render. Skipping pull leaves the queue full (IDEUI1001). Offline golden:Examples/Web/ui_event_loop.malda. Engine notes:docs/ui-framework.md. - State:
ui.state(componentId, key, defaultValue, scope?)(get-or-create — persists default),ui.getState(componentId, key, defaultValue?, scope?)(peek — does not write),ui.setState(componentId, key, value, scope?),ui.pinState(componentId, scope?),ui.unpinState(componentId, scope?) - Invalidation:
ui.invalidate(channel, payload?) - Diagnostics/recovery:
ui.snapshot(sessionId?),ui.resync(sessionId?) - Policy controls:
ui.configure(settingName, value, sessionId?) - Session helpers:
ui.sessionId(source, defaultSessionId?),ui.redirectWithSession(path, source, defaultSessionId?)
ui.configure supports maxPatchCount, maxPayloadBytes, maxEventQueueDepth, and sessionTtlMs.
var envelope = ui.mountEnvelope(rootNode, "app-session", {
"maxPatchCount": 1024,
"maxEventQueueDepth": 1024,
"sessionTtlMs": 1800000
});
print(envelope.mount.type);
print(envelope.snapshot.version);
print(envelope.resync.type);
// source can be request object, body object, or query/body map containing "session"
var sessionId = ui.sessionId(req, "items-ui");
return ui.redirectWithSession("/web/items", body, "items-ui");
Lifecycle events are emitted through ui.pullEvent() as type = "lifecycle" payloads. Typical ordering is:
ui.mount:onInit, thenonMountui.renderfor tracked components:onPreRenderbefore diff- Existing component on render:
onUpdate - New component discovered during render:
onMount, thenonLoad - Removed component:
onUnmount, thenonDispose - Session disposal/expiry: tracked components emit
onDispose
Subscribe by calling the matching helper with the component id (and optional session). That registers the hook; the payload still arrives on ui.pullEvent as type = "lifecycle". Offline showcase: Examples/Web/ui_counter_dashboard.malda.
ui.onMount("CounterRoot", sessionId);
ui.onUpdate("CounterRoot", sessionId);
ui.onUnmount("CounterRoot", sessionId);
24.3.3.1 How a ui.* session is hosted
A program that calls ui.mount or ui.mountEnvelope can start the embedded UIHost (CLI and Desktop). That host is not HttpServer / @PAGE — those stay in 25. HttpServer & HTML UI Generation.
- The browser client is
malda-ui-client.js. The session key is thesessionIdyou pass toui.mount. - Typical endpoints: WebSocket
/ui/ws/{sessionId}, plus HTTP mount/patch forwarding under/ui/mount/{sessionId}and/ui/patch/{sessionId}. - Optional environment:
MALDA_UI_AUTH_TOKEN(shared secret) andMALDA_UI_ALLOWED_ORIGIN(CORS). - Patch protocol, envelope fields, and host internals:
docs/ui-framework.md.
To try a tree without a browser, run the offline loop below (or Examples/Web/ui_event_loop.malda). AI-generated trees use ui.generate — see 25.8.5.
24.3.3.2 Event loop (mount → pull → render)
After each client event: dispatchEvent (the host enqueues; examples may simulate) → pullEvent → update state → rebuild the tree → ui.render. Skipping pullEvent leaves the queue full and the UI looks stuck (IDE UI1001).
var sessionId = "rm-event-loop";
var count = ui.state("LoopRoot", "count", 0, sessionId);
function buildView(value) {
return ui.column(
{"componentId": "LoopRoot"},
[
ui.text({"value": "count=" + string(value)}),
ui.button({"label": "Inc", "onClick": "increment"})
]
);
}
var mounted = ui.mount(buildView(count), sessionId);
print(mounted.type);
ui.dispatchEvent(
{"type": "click", "targetPath": "/1/", "payload": {"action": "increment"}},
sessionId
);
var evt = ui.pullEvent(sessionId);
if (evt != null) {
count = count + 1;
ui.setState("LoopRoot", "count", count, sessionId);
}
var patched = ui.render(buildView(count), sessionId);
print(patched.type);
print(count);
24.3.4 UI control catalog
UI controls follow a consistent signature: ui.control(props, children?, key?).
- Layout:
ui.row,ui.column,ui.stack,ui.spacer,ui.panel - Display:
ui.text,ui.heading,ui.image,ui.icon - Inputs:
ui.button,ui.textField,ui.checkbox,ui.select,ui.slider,ui.datePicker - Forms/navigation:
ui.form,ui.field,ui.textArea,ui.radioGroup,ui.switch,ui.tabs,ui.accordion,ui.breadcrumbs,ui.drawer - Data/feedback:
ui.list,ui.table,ui.alert,ui.progress,ui.modal,ui.dataGrid,ui.treeView,ui.paginator,ui.emptyState,ui.badge,ui.toast,ui.skeleton,ui.spinner,ui.errorBoundary - Composition:
ui.slot,ui.withSlot,ui.when,ui.choose,ui.each,ui.template,ui.partial,ui.layout,ui.renderList,ui.crudModel,ui.crudControls,ui.crudSchema,ui.mountEnvelope
ui.datePicker(props, children?, key?) renders a native date (or date+time) input. Props: name, value, defaultValue, placeholder, disabled, includeTime. Set includeTime to true for a date-and-time picker. Use ISO date strings for value/defaultValue: YYYY-MM-DD or YYYY-MM-DDTHH:mm. Events: onChange, onInput.
24.3.4.1 Styling controls
Use a hybrid styling approach for Web UI controls: apply reusable design rules with className, and apply per-instance/dynamic tweaks with style.
className: CSS class string applied directly to the rendered element.style(string): CSS text, for example"margin:8px; color:#1f2937;".style(object): property map, for example{"margin": "8px", "backgroundColor": "#f5f7fb"}.- When re-rendered, the latest
stylevalue replaces the previous style value for that control.
var card = ui.panel({
"className": "dashboard-card",
"style": {"padding": "12px", "borderRadius": "10px", "backgroundColor": "#ffffff"}
}, [
ui.text({"value": "Hybrid styling example"})
]);
24.3.5 Advanced data controls
ui.dataGrid(props, children?, key?) supports sorting/filtering/pagination, row selection, optional virtualization, and drag/drop row interaction events.
Sorting by clicking column headers works with sortable columns. If onSort is provided, the grid emits sort events for server-side/state-managed sorting; otherwise the client applies local sorting using the current sort state.
- Core props:
columns,rows,rowKey,selectionMode,selectedKeys - Data operations:
sortable,sort,filter,page,pageSize,totalItems - Virtualization:
virtualize,rowHeight,overscan, optionalheight - Events:
onRowClick,onSelectionChange,onSort,onFilter,onPageChange,onViewportChange,onDragStart,onDragOver,onDrop,onDragEnd
ui.treeView(props, children?, key?) provides hierarchical navigation with expand/collapse, keyboard-friendly selection, optional lazy loading, and drag/drop node interaction events.
- Core props:
nodes,nodeKey,expandedKeys,selectedKeys,selectionMode - Options:
showLines,lazy - Events:
onNodeSelect,onNodeToggle,onNodeExpand,onNodeCollapse,onNodeActivate,onLoadChildren,onDragStart,onDragOver,onDrop,onDragEnd
When lazy is enabled, expanding a node without loaded children should trigger server-side loading (typically through onLoadChildren) and then re-render with the returned children.
var grid = ui.dataGrid({
"columns": [{"key": "name", "title": "Name", "sortable": true}],
"rows": [{"id": 1, "name": "Andrea"}],
"rowKey": "id",
"selectionMode": "single",
"onSort": "sortUsers",
"onSelectionChange": "selectUser",
"onDrop": "moveUserRow",
"virtualize": true,
"rowHeight": 32,
"overscan": 4
});
var tree = ui.treeView({
"nodes": [{"id": "root", "label": "Root", "hasChildren": true, "childrenLoaded": false}],
"lazy": true,
"expandedKeys": ["root"],
"onNodeToggle": "toggleNode",
"onNodeSelect": "selectNode",
"onLoadChildren": "loadTreeChildren",
"onDrop": "moveTreeNode"
});
24.3.6 Control props reference
Contracts below match UiControlSpecRegistry. Every control also accepts the shared props className, id, role, ariaLabel, componentId, key, disabled, and style. Event props are action-name strings in the emitted payload — keep those names stable across renders.
Layout (ui.row, ui.column, ui.stack, ui.spacer, ui.panel, ui.field, ui.drawer, ui.tabs, ui.accordion, ui.breadcrumbs, ui.list, ui.table, ui.emptyState, ui.badge, ui.alert, ui.progress, ui.skeleton, ui.spinner, ui.errorBoundary, ui.slot, ui.when, ui.choose, ui.each): shared props only. ui.tabs commonly uses a custom value (active tab key) with ui.panel children that have a key.
| Control | Extra props | Events |
|---|---|---|
ui.text / ui.heading | value (required) | — |
ui.image | src, alt, width, height | — |
ui.icon | name, size | — |
ui.button | label, variant (primary|secondary|danger) | onClick |
ui.textField | name, value, defaultValue, placeholder | onChange, onInput |
ui.textArea | same as textField, plus rows | onChange, onInput |
ui.checkbox / ui.switch | name, checked, defaultChecked, label | onChange |
ui.select / ui.radioGroup | name, value, defaultValue, options (array of {"label","value"}) | onChange |
ui.slider | name, value, min, max, step | onChange, onInput |
ui.datePicker | name, value, defaultValue, placeholder, includeTime (ISO YYYY-MM-DD or YYYY-MM-DDTHH:mm) | onChange, onInput |
ui.form | method, action (plus shared componentId) | onSubmit |
ui.modal | open, title | onClose |
ui.toast | open, message, variant (success|warning|error|info) | onClose |
ui.paginator | page, pageSize, totalItems | onChange |
ui.dataGrid | see 24.3.5 | see 24.3.5 |
ui.treeView | see 24.3.5 | see 24.3.5 |
Unknown custom attributes are accepted (permissive mode). Event names that are not in the table throw at tree construction.
24.3.7 Control usage examples
Implementation-ready snippets for common form and list patterns:
// Button with primary variant and action
ui.button({"label": "Save", "variant": "primary", "onClick": "saveItem"})
// Form with submit action and validation
ui.form({"componentId": "ItemForm", "onSubmit": "submitItem"}, [
ui.field({}, [ui.text({"value": "Title"}), ui.textField({"name": "title", "required": true})]),
ui.button({"label": "Create", "variant": "primary", "onClick": "submit"})
])
// Modal with close handler
ui.modal({"open": true, "title": "Edit item", "onClose": "closeEditDialog"}, [
ui.text({"value": "Form content here"})
])
// Tabs with active state
ui.tabs({"value": "overview"}, [
ui.panel({"key": "overview"}, [ui.text({"value": "Overview content"})]),
ui.panel({"key": "details"}, [ui.text({"value": "Details content"})])
])
// DataGrid with row click and selection
ui.dataGrid({
"columns": [{"key": "name", "title": "Name", "sortable": true}],
"rows": [{"id": 1, "name": "Alice"}],
"rowKey": "id",
"selectionMode": "single",
"onRowClick": "openDetail",
"onSelectionChange": "selectRow"
})
// TreeView with lazy loading
ui.treeView({
"nodes": [{"id": "r", "label": "Root", "hasChildren": true, "childrenLoaded": false}],
"lazy": true,
"expandedKeys": ["r"],
"onLoadChildren": "loadChildren",
"onNodeSelect": "selectNode"
})
// Toast and error boundary
ui.toast({"open": true, "message": "Saved", "variant": "success", "onClose": "dismissToast"})
ui.errorBoundary({}, [ui.text({"value": "Something went wrong. Please try again."})])
24.3.8 Event handling patterns
Wire user interactions to server actions using consistent patterns:
- onClick: button clicks and link-like triggers. Pass
{"action": "handlerName"}in payload when dispatching. - onSubmit: form submits. Form data is serialized and sent in the event payload.
- onChange / onInput: field value changes. Use for real-time validation or debounced updates.
Action names are strings your server-side event loop maps to behavior. In server UI flows, process the pulled event payload, update state, and then emit fragments or re-render.
// Client: form submits; server returns fragment or JSON
ui.form({"onSubmit": "submitItem"}, [
ui.textField({"name": "title", "onChange": "validateTitle"}),
ui.button({"label": "Submit"})
])
// Server: process events from the queue
ui.dispatchEvent({"type": "submit", "targetPath": "/", "payload": {"action": "submitItem", "title": "Bug report"}}, sessionId);
var evt = ui.pullEvent(sessionId);
if (evt != null && evt.payload != null && evt.payload.action == "submitItem") {
// validate/persist, then ui.setState(...) or return componentFragment(...)
}
For template-based forms, use method="post" and action="/path/..." with the built-in AJAX helper. See 29. Full-Stack Development with MALDA for end-to-end patterns.
24.3.9 Form validation patterns and error rendering
Validate input on the server before persisting. Return validation errors in the response and render them next to fields:
- Use
requiredon inputs for mandatory fields; HTML5 validation runs before submit. - Server-side: validate in
@ACTIONor@POSThandlers; returncomponentFragment(...)with inline error markup. - Render errors with
ui.alertorui.textwithvariant="error"/className="error".
@ACTION("/items/add")
function addItem(body) {
var errors = [];
if (body.title == null || body.title == "") {
errors.append("Title is required");
}
if (body.email != null && indexOf(body.email, "@") < 0) {
errors.append("Invalid email");
}
if (errors.length > 0) {
var errHtml = "<ul class='error-list'>";
var i = 0;
while (i < errors.length) {
errHtml = errHtml + "<li>" + errors[i] + "</li>";
i = i + 1;
}
errHtml = errHtml + "</ul>";
return componentFragment("item-form-errors", errHtml);
}
// persist and return success fragment
return componentFragment("item-list", "<ul><li>" + body.title + "</li></ul>");
}
Security: Always escape user-supplied content before embedding in HTML. Use {{key}} (escaped interpolation) in templates, or use a trusted escaping helper for raw string concatenation. Never use {{{key}}} for user input.
24.3.10 Composition patterns
Build reusable UI from smaller pieces using ui.slot, ui.withSlot, ui.when, ui.choose, and ui.each:
- ui.slot({"name": name}): placeholder node. Inject later with
ui.withSlot(parent, name, content). - ui.when(condition, thenNode, elseNode?): returns
thenNodewhen the condition is true; otherwiseelseNode, ornullif omitted. - ui.choose(key, casesObject, defaultNode?): picks
casesObject[string(key)], ordefaultNode/null. - ui.each(items, propName?): returns an array of
ui.textnodes, not a list control. Each item is stored inprops[propName](default"value"). Pass that array as children ofui.column/ui.list.
var card = ui.panel({}, [ui.slot({"name": "title"}), ui.slot({"name": "body"})]);
var cardWithTitle = ui.withSlot(card, "title", ui.heading({"value": "Card Title"}));
var cardComplete = ui.withSlot(cardWithTitle, "body", ui.text({"value": "Body content"}));
var isOpen = true;
var status = "success";
var statusBadge = ui.when(isOpen, ui.badge({"value": "Open"}), ui.badge({"value": "Closed"}));
var alertByType = ui.choose(status, {"success": ui.alert({"variant": "success"}, [ui.text({"value": "OK"})])}, ui.alert({"variant": "info"}, [ui.text({"value": "Unknown"})]));
var labels = ui.each(["a", "b", "c"]);
var list = ui.column({}, labels);
24.3.11 State management patterns
HttpServer request handlers run in an isolated interpreter, so top-level variables are not shared across requests. Use the component state store (via ui.* or componentState*) for cross-request data.
- Peek vs get-or-create:
ui.getState/componentStateGetreturn an optional default without writing.ui.statepersists the default when the key is missing — never usenullor{}as the default for critical keys after a possible TTL/LRU miss. IDE/LSP reports UI1003 (Warning) for those literal poison defaults (also flatuiState). - Pin for process-lifetime data: after writing shared catalog/config, call
ui.pinState(componentId, scope)(orcomponentStatePin) so TTL and LRU eviction skip that entry. Leave per-conversation history unpinned. - ui.setState / componentStateSet: write values for both short-lived UI flags and durable server data.
Rule of thumb: peek with ui.getState when reading; use ui.state only when you intentionally want get-or-create. Pin shared brain/config scopes; keep conversation scopes under normal TTL/LRU. Persist long-term invariants in files or a database when the process may restart. Offline golden: Examples/Web/ui_state_lifecycle.malda — also docs/ui-framework.md § State model.
// Peek (safe): missing key does not poison the store
var catalog = ui.getState("AskStore", "catalog", null, sharedScope);
// Get-or-create (OK for lists you always want initialized)
var items = ui.state("board", "items", [], convScope);
items.append({"title": "New", "status": "open"});
ui.setState("board", "items", items, convScope);
// Process-lifetime shared meta
ui.setState("AskStore", "session", meta, sharedScope);
ui.pinState("AskStore", sharedScope);
24.3.12 Routing and query param patterns
For master-detail and multi-page flows, use query parameters:
- List page:
/web/items?search=...&status=open&sort=id_desc - Detail page:
/web/items?id=42or path param/web/items/42 - Parse
req.queryin page handlers; pass toui.crudControlsorui.crudModelwhen using schema-driven CRUD.
@PAGE("/web/items")
function itemsPage(req) {
var queryParams = req.query == null ? {} : req.query;
var sessionId = ui.sessionId(req, "items-ui");
var schema = itemsCrudSchema;
var lookups = {"owners": listOwners(), "tags": listTags()};
var controlsHtml = ui.crudControls(schema, sessionId, queryParams, lookups);
return appLayoutHtml("items", "Items", controlsHtml, "");
}
For REST API calls from the client, use fetch or RestClient; see 28. REST Web Client for server-side HTTP calls and 27. REST API Server for API design.
24.3.13 Error and security notes
- Escaping: Use
{{key}}in templates for user-supplied content (escaped by default). Use{{{key}}}only when you intentionally need raw HTML (e.g. pre-rendered badges from trusted sources). - CSRF: Enable
enableCsrf(secret, cookieName?, headerName?)onHttpServerfor mutating routes. The client must send the CSRF token in the header or form field. - Validation: Validate all request input in
@ACTIONand@POSThandlers; return 400 with structured errors for API clients.
24.3.14 Styling guidance
Keep layout and CSS conventions consistent across pages:
- Use
classNamefor semantic classes:btn primary,nav-link,panel stack,muted. - Layout:
stackfor vertical stacking,rowfor horizontal flow; usestylefor grid columns (grid-template-columns). - App templates can provide layout, sidebar, and entity controls; reference shared CSS via a public path helper.
var body = ui.layout(
"templates/layout.html",
{"sidebar": sidebarHtml, "pageBody": bodyHtml},
{"pageTitle": "Items", "appCssPublicPath": "/static/app.css"}
);
24.3.15 Common pitfalls and debugging
- Fragment not updating: Ensure
X-Malda-FragmentandX-Malda-Fragment-Targetheaders are set; the client script must read them and update the target element. - State lost between requests:
componentState*is session-scoped; passsessionin query/body or use a cookie. - Event handler not firing / UI stuck after dispatch: Drain with
ui.pullEvent, update state, rebuild, thenui.render. IDE warningUI1001flags dispatch→render without pull. SeeExamples/Web/ui_event_loop.malda. - Mixed @PAGE and ui.* trees: Prefer one model per surface; IDE
UI1002(Info) when a file mixes both. Do not pass HTML strings intoui.mount/ui.render. Hub: 23. Web UI Overview. - Template cache: Pass
{"cache": false}toui.templatefor development reload. - Tree divergence: Use
ui.snapshotandui.resyncwhen client and server trees diverge.
24.4 Request-to-Render Lifecycle
- Browser sends request to
@PAGE,@AIPAGE,@ACTION, or@GET/@POSTroute. - Server resolves route and builds HTML or a component fragment.
- For component flows, server updates state (
componentState*orui.state/setState). - Server returns either a full document, fragment response, or protocol patch envelope.
- Optional: SSE subscribers receive
componentLiveEmit/ui.invalidatemessages for live updates.
For the route-first parts of this lifecycle, including HttpServer, @PAGE, and AI-generated HTML, see HttpServer & HTML UI Generation.
24.5 Production Hardening Checklist
- Enable CSRF protection on mutating routes (
enableCsrf) and verify header/cookie flow end-to-end. - Apply rate limits (
setRateLimit) and set an explicit key strategy for authenticated APIs. - Validate request input (
@Validate) and return standardized API errors for JSON clients. - Set sensible
ui.configurelimits (maxPayloadBytes,maxEventQueueDepth,sessionTtlMs). - Use server-managed state for business invariants and keep client state transient.
- Add correlation IDs in logs for route, action, and SSE troubleshooting.
24.6 Testing and Troubleshooting
- Use
getRoutes()to verify route registration for page/action/live endpoints. - Test fragment responses by asserting
X-Malda-FragmentandX-Malda-Fragment-Targetheaders. - For SSE issues, verify channel query parameters and test with a minimal
EventSourceclient. - When client trees diverge, inspect
ui.snapshotoutput and triggerui.resync. - For generated server-component trees, review the result of
ui.mountEnvelopeand re-runui.resyncif a client falls behind.
24.7 Related Chapters and Examples
- 25. HttpServer & HTML UI Generation covers
HttpServer,@PAGE,@AIPAGE, redirects,HTMLCache,extractHTML,generateUI, andui.generate(25.8.5) for AI-builtui.*trees. - 26. Browser JavaScript UI Backend covers MALDA-to-JavaScript compilation,
dom.*, template mode, browser actors, andgame.*. - REST API Server remains the main reference for API design, middleware, validation, and API-first
@GET/@POSTroutes.
Offline and hosted samples under Examples/Web/:
ui_event_loop.malda— mount → dispatch → pull → setState → renderui_counter_dashboard.malda— same loop plus lifecycle hooks and snapshotui_state_lifecycle.malda— peek vs get-or-create, pin, poison defaultsui_form_workflow.malda/ui_controls_showcase_minimal.malda— forms and control catalogui_resync_flow.malda— snapshot / resync when client and server diverge
See Also
- 23. Web UI Overview - Choose a UI model
- 25. HttpServer & HTML UI Generation - Route-first hosting,
@PAGE, and AI HTML generation - 26. Browser JavaScript UI Backend - MALDA transpiled to JavaScript for browser-hosted UI
- 33. Examples - Server-driven UI sample index
- 29. Full-Stack Development with MALDA - Architecture and delivery models