Data Binding
In JSON-UI, data flows from the handler into spec elements through two expression shapes placed as prop values. The handler provides a plain JSON object; expressions read from it at render time.
Handler Data Shape
The handler assembles data and passes it as the second argument to JsonUi::render_file:
#![allow(unused)] fn main() { #[handler] pub async fn index(req: Request) -> Response { let orders = Order::find_all(&req.db()).await?; let stats = Stats::load(&req.db()).await?; JsonUi::render_file( "src/views/orders.json", serde_json::json!({ "orders": orders, "stats": { "total": stats.total, "revenue": stats.revenue_formatted }, "user": { "name": req.auth_user().name, "role": req.auth_user().role } }), ) } }
render_file loads the spec file (with caching), merges the handler data into spec.data, resolves all expressions, and returns the rendered HTML response. The handler contains no component-building code — only data assembly.
Expressions
Expressions are JSON objects with a single recognized key. They appear as prop values inside elements. There are two expression types: $data and $template.
$data — type-preserving extraction
Format:
{ "$data": "/json/pointer/path" }
$data extracts the value at the given JSON Pointer path from the spec's data context. The resolved value preserves its original type — a string stays a string, a number stays a number, a boolean stays a boolean, an array stays an array.
Missing paths resolve to null.
Example — extract a number:
"revenue_stat": {
"type": "StatCard",
"props": {
"label": "Total Revenue",
"value": { "$data": "/stats/revenue" }
}
}
If the data is { "stats": { "revenue": 12345 } }, the value prop resolves to 12345 (number).
Example — extract a string:
"user_badge": {
"type": "Badge",
"props": {
"label": { "$data": "/user/role" }
}
}
With data { "user": { "role": "admin" } }, label resolves to "admin" (string).
Example — extract an array:
"product_list": {
"type": "DataTable",
"props": {
"data_path": "/products"
}
}
Note: data paths in DataTable (data_path), KanbanBoard (items_path), Input, Select, Checkbox, and Switch (data_path) are plain string JSON Pointers — not $data expressions. The component reads rows, items, or pre-fills values from that path at render time, but the path itself is literal.
$template — string interpolation
Format:
{ "$template": "literal text {/path} more text" }
$template produces a string by substituting {/path} placeholders with values from the data context. Each placeholder uses JSON Pointer syntax. The result is always a string regardless of what the placeholders resolve to.
Missing placeholders substitute as "" (empty string). To emit a literal { or } character, escape it with a backslash: \{ and \}.
Example — greeting message:
"welcome_text": {
"type": "Text",
"props": {
"content": { "$template": "Welcome, {/user/name}!" },
"element": "h2"
}
}
With data { "user": { "name": "Alice" } }, content resolves to "Welcome, Alice!".
Example — formatted label:
"order_heading": {
"type": "Text",
"props": {
"content": { "$template": "Order #{/order/id} — {/order/status}" },
"element": "h3"
}
}
With data { "order": { "id": 1042, "status": "pending" } }, content resolves to "Order #1042 — pending".
Where Expressions Apply
Expressions are resolved in element.props values only. They are not resolved in:
spec.title— literal stringspec.layout— literal stringspec.data— the data source itselfelement.children— always a list of element ID stringselement.action— handler name and method are literalelement.visible— visibility condition fields are literal
Expressions can appear at any depth inside a props object or array, but not as keys — only as values.
Complete Example
A handler + spec showing $data, $template, and a plain data_path:
Handler (src/controllers/payments.rs):
#![allow(unused)] fn main() { #[handler] pub async fn index(req: Request) -> Response { let payments = Payment::find_all(&req.db()).await?; let total = payments.iter().map(|p| p.amount).sum::<f64>(); JsonUi::render_file( "src/views/payments.json", serde_json::json!({ "payments": payments, "stats": { "total": format!("€{:.2}", total), "count": payments.len() }, "user": { "name": req.auth_user().name } }), ) } }
Spec (src/views/payments.json):
{
"$schema": "ferro-json-ui/v2",
"title": "Payments",
"layout": "dashboard",
"root": "page_header",
"elements": {
"page_header": {
"type": "PageHeader",
"props": {
"title": "Payments",
"description": { "$template": "Welcome, {/user/name}" }
},
"children": ["stats_grid"]
},
"stats_grid": {
"type": "Grid",
"props": { "columns": 2, "gap": "md" },
"children": ["total_stat", "count_stat"]
},
"total_stat": {
"type": "StatCard",
"props": {
"label": "Total",
"value": { "$data": "/stats/total" }
}
},
"count_stat": {
"type": "StatCard",
"props": {
"label": "Payments",
"value": { "$data": "/stats/count" }
}
},
"payments_table": {
"type": "DataTable",
"props": {
"data_path": "/payments",
"columns": [
{ "key": "date", "label": "Date", "format": "date" },
{ "key": "description", "label": "Description" },
{ "key": "amount", "label": "Amount", "format": "currency" },
{ "key": "status", "label": "Status" }
],
"empty_message": "No payments recorded."
}
}
}
}
$data and $template resolve at render time from the handler data. data_path in DataTable is a plain string pointer the component uses to read rows from the same data.
Single-Pass Guarantee
If a $data expression resolves to a string value that looks like {"$data": "/another/path"}, the inner expression is not re-resolved. This is intentional — it prevents injection. Expressions are evaluated in a single pass.
Hard Cap — What Does Not Exist
The expression language is intentionally minimal. The following do not exist and will not be added:
$if— no conditional rendering in expressions$for— no loops in expressions$state— no client-side state$bind— no two-way binding$map— no array transformation in expressions
Conditional logic belongs in the Rust handler. Use the visible condition on elements for simple show/hide logic based on data values (see Visibility). Complex branching is handled server-side before render_file is called — shape the data differently, or call render_file with a different spec path.
Visibility Conditions
Elements can be conditionally shown or hidden with the "visible" field. Visibility is not an expression — it is a separate condition checked against data paths during render.
"admin_panel": {
"type": "Card",
"props": { "title": "Admin Tools" },
"visible": { "field": "/user/role", "op": "eq", "value": "admin" }
}
For the full visibility operator reference, see Visibility.
data_path Reference
data_path is a plain JSON-pointer string (not a $data expression). It is supported by three component families and follows a consistent resolution convention: the path is walked against the full handler data object, and the component reads whatever value resides there.
DataTable — row array
data_path: "/data/{service.name}"
Resolves to an array of row objects. Each row is one <tr>; column key fields project as cell text.
"staff_table": {
"type": "DataTable",
"props": {
"data_path": "/data/staff",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "active", "label": "Active", "format": "boolean" }
]
}
}
Handler provides { "data": { "staff": [ ... ] } }.
KanbanBoard — fixed lanes, flat item array
items_path: "/data/{service.name}"
group_by: "<status field>"
A kanban is fixed lanes plus items sorted into them by a status field. The
spec's columns array (lane id + title, derived from the service's state
machine under the projection renderer) is structure and is always rendered.
items_path resolves the same flat entity array DataTable reads, and the
renderer buckets each item into the lane whose id equals the item's
group_by value — so handlers stay flat and need no per-lane grouping.
"order_kanban": {
"type": "KanbanBoard",
"props": {
"columns": [
{ "id": "draft", "title": "Draft" },
{ "id": "submitted", "title": "Submitted" }
],
"items_path": "/data/order",
"group_by": "status",
"card_title_key": "name"
}
}
Handler provides the flat array (same shape as the DataTable data path):
{
"data": {
"order": [
{ "id": 1, "name": "#1", "status": "draft" },
{ "id": 2, "name": "#2", "status": "submitted" }
]
}
}
StatCard — scalar value
StatCardProps.value_path resolves to a single scalar (string or number):
value_path: "/data/{service.name}/{field.name}"
"revenue_stat": {
"type": "StatCard",
"props": {
"label": "Total Revenue",
"value": "",
"value_path": "/data/statistics/total_revenue"
}
}
Handler provides { "data": { "statistics": { "total_revenue": "€12,450" } } }. The static value string is the fallback when value_path is absent or fails to resolve.