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_path in DataTable, Input, Select, Checkbox, and Switch is a plain string JSON Pointer — not a $data expression. The component reads rows 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.