Data binding is the mechanism that connects V2 form components to live data. Instead of hard-coding values in props, you write JavaScript expressions in bindings that are evaluated against a data context. When the underlying data changes, bindings are re-evaluated and the UI updates automatically.
This guide covers the full binding system: how bindings work, the shape of the data context, loop scoping with for, computed bindings that reference named scripts, and the reactivity model that drives updates.
For the scripting language details and sandbox limitations, see the Scripting guide.
How Bindings Work
Every UINode can have a bindings object that maps prop names to JavaScript expressions. At render time, each expression is evaluated with the current data context injected as ctx. The result replaces (or supplements) the corresponding value in props.
{
"component": "card:label",
"props": {
"visible": true
},
"bindings": {
"label": "ctx.dataObjects?.[0]?.path ?? 'No data'"
}
}
In this example, visible is a static prop (always true), while label is a dynamic binding that evaluates against the data context. If both props and bindings define the same key, the binding takes precedence.
Expression Evaluation
Binding expressions are evaluated by wrapping them in an immediately-invoked function:
(function() {
const ctx = { /* current data context */ };
return (/* your expression */);
})()
This means your expression is a single JavaScript expression (not a statement block). You can use:
- Optional chaining:
ctx.dataObjects?.[0]?.path
- Nullish coalescing:
ctx.value ?? 'default'
- Ternary operators:
ctx.count > 0 ? 'Has items' : 'Empty'
- Template literals:
`Total: ${ctx.count} items`
- Array methods:
ctx.dataObjects?.filter(o => o.path === 'invoice').length
- Arithmetic:
ctx.amount * 1.1
Binding expressions should be pure — they should not modify any state, make API calls, or produce side effects. The runtime may re-evaluate them at any time when the data context changes.
Data Context Shape
The ctx object passed to binding expressions contains the current data context. At the root level, it has the following shape:
interface DataContextV2 {
dataObjects?: DataObject[]; // All data objects in scope
tagMetadataMap?: Map<string, any>; // Tag metadata keyed by taxonomy path
$item?: any; // Current item in a for loop
$index?: number; // Current index in a for loop
$parent?: DataContextV2; // Parent context (in nested loops)
$root?: DataContextV2; // Root context (always the top-level)
[key: string]: any; // Additional keys injected by the runtime
}
Root Context
At the top level (outside any for loop), ctx contains:
| Property | Type | Description |
|---|
ctx.dataObjects | DataObject[] | All data objects from the workspace store |
ctx.tagMetadataMap | Map<string, any> | Tag metadata from the project store, keyed by taxonomy path |
DataObject Shape
Each data object in ctx.dataObjects has the following properties (among others):
{
uuid: string; // Unique identifier
path: string; // Taxonomy path (e.g., "invoice", "lineItem")
parent?: { uuid: string }; // Parent reference
attributes?: DataAttribute[]; // Child attributes
}
DataAttribute Shape
{
uuid: string;
path: string; // Full path (e.g., "invoice/totalAmount")
value: any; // The attribute value
dataObjectId: number; // Parent data object ID
}
Accessing Data in Expressions
{
"bindings": {
"label": "ctx.dataObjects?.find(o => o.path === 'invoice')?.attributes?.find(a => a.path === 'invoice/invoiceNumber')?.value ?? 'N/A'"
}
}
For complex lookups, consider using a named script via computed instead of a long inline expression.
Loop Scoping
The for configuration on a UINode renders the node once for each item in a source collection. Inside the loop, the data context is extended with loop-specific variables.
{
"component": "card:cardPanel",
"for": {
"source": "ctx.dataObjects?.filter(o => o.path === 'lineItem')",
"itemAs": "$item",
"indexAs": "$index",
"key": "$item.uuid"
},
"bindings": {
"title": "'Line Item #' + ($index + 1)"
},
"children": [
{
"component": "card:label",
"bindings": {
"label": "$item.attributes?.find(a => a.path === 'lineItem/description')?.value ?? 'No description'"
}
}
]
}
Loop Variables
| Variable | Type | Description |
|---|
$item | any | The current element from the source array. The name is configurable via itemAs. |
$index | number | The zero-based index of the current element. The name is configurable via indexAs. |
$parent | DataContextV2 | The parent data context (the context from before the loop started). |
$root | DataContextV2 | The root-level data context (always the top-level context). |
Nested Loops
When you nest for iterators, each level creates a new scope. Use $parent to access the outer loop’s context:
{
"component": "card:cardPanel",
"for": {
"source": "ctx.dataObjects?.filter(o => o.path === 'invoice')",
"itemAs": "$item",
"key": "$item.uuid"
},
"bindings": {
"title": "'Invoice: ' + $item.uuid.slice(0, 8)"
},
"children": [
{
"component": "card:label",
"for": {
"source": "ctx.dataObjects?.filter(o => o.path === 'lineItem' && o.parent?.uuid === $item.uuid)",
"itemAs": "$item",
"indexAs": "$lineIndex",
"key": "$item.uuid"
},
"bindings": {
"label": "'Line ' + ($lineIndex + 1) + ' of invoice ' + $parent.$item?.uuid?.slice(0, 8)"
}
}
]
}
When a nested loop uses the same itemAs name as an outer loop, the inner variable shadows the outer one. Use $parent.$item to access the outer item. For clarity, consider using distinct names like $invoice and $lineItem.
Key Expressions
The key field in ForConfig is evaluated per item and must produce a unique, stable value. This helps the renderer efficiently update the DOM when items are added, removed, or reordered.
Good key expressions:
"$item.uuid" — unique and stable
"$item.id" — unique within a document
Avoid using $index as a key, since it changes when items are reordered.
Computed Bindings
The computed object maps prop names to named scripts from the scripts or scriptModules registry. The script is called with the current data context and its return value becomes the prop.
{
"version": "2",
"scripts": {
"totalAmount": "function(ctx) { return ctx.dataObjects?.filter(o => o.path === 'lineItem').reduce((sum, o) => { const amt = o.attributes?.find(a => a.path === 'lineItem/amount'); return sum + (Number(amt?.value) || 0); }, 0).toFixed(2) ?? '0.00'; }"
},
"nodes": [
{
"component": "card:label",
"computed": {
"label": "totalAmount"
}
}
]
}
When to Use computed vs bindings
Use bindings when… | Use computed when… |
|---|
| The expression is simple and self-contained | The logic is complex or multi-step |
| The expression is only used in one place | The same logic is reused across multiple nodes |
| You want inline visibility of the logic | You want to name and document the logic |
Reactivity Model
V2 forms use Vue’s reactivity system under the hood. The data context is provided via Vue’s provide/inject mechanism, and bindings are evaluated inside Vue computed properties. This means:
- Automatic updates: When
dataObjects or tagMetadataMap change in the store, all bindings that reference them are automatically re-evaluated.
- Efficient updates: Vue only re-evaluates bindings whose dependencies have changed. If a binding only reads
ctx.dataObjects[0].path, it will not re-evaluate when a different data object changes.
- Synchronous evaluation: Binding expressions are evaluated synchronously. Async operations (API calls, etc.) should be performed in event handlers, not bindings.
Data Flow
Workspace Store (data objects, attributes)
↓ (Vue reactive provide)
Schema Root (root data context)
↓ (provide/inject)
Schema Node (evaluates bindings against ctx)
↓ (resolved props)
Card Component (renders with resolved props)
Triggering Updates
The data context updates automatically when:
- Data objects are added, removed, or modified in the workspace store
- Attributes are updated via the data attribute editor or via
kodexa.data.setAttribute
- Tag metadata changes in the project store
You do not need to manually trigger re-evaluation. The Vue reactivity system handles this.
Common Patterns
Conditional Display Based on Data
{
"component": "card:label",
"if": "ctx.dataObjects?.some(o => o.path === 'invoice')",
"bindings": {
"label": "'Found ' + ctx.dataObjects.filter(o => o.path === 'invoice').length + ' invoice(s)'"
}
}
Default Values
{
"bindings": {
"label": "ctx.$item?.attributes?.find(a => a.path === 'invoice/vendor')?.value || 'Unknown Vendor'"
}
}
Derived Styling
{
"bindings": {
"backgroundColor": "ctx.$item?.attributes?.find(a => a.path === 'lineItem/amount')?.value > 10000 ? '#fef3c7' : '#ffffff'"
}
}
Combining Static Props with Bindings
{
"component": "card:cardPanel",
"props": {
"showHeader": true,
"useTabs": true,
"groupTaxon": "invoice"
},
"bindings": {
"title": "'Invoices (' + (ctx.dataObjects?.filter(o => o.path === 'invoice').length ?? 0) + ')'"
}
}
Next Steps