← ConceptsData binding as contract`@{variable}` is how a layout JSON names a field on its ViewModel. What the field holds, how strongly it is type-checked, and whether the widget can write back into it depends on the platform and on the widget — the Layout JSON stays the same across all three.~4 min read
Where bindings liveBindings are not limited to text. Any attribute value that is not a layout primitive (`type`, `orientation`, `style` name) can be bound to a ViewModel field. Group the real uses into five families — text (Label `text`, TextField `hint`), style (colors, opacity, fontWeight, borderWidth), layout (width, height, `visibility` — which is a `String` enum `'visible' | 'invisible' | 'gone'`, not a Bool), events (`onClick`, `onTextChange`, `onValueChanged`, `onPageChanged`), and collection / resource (`items` on a Collection, `src` on a NetworkImage, `currentPage` on a pager).
five families of bindings
// 1. TEXT — Label text, TextField hint, CodeBlock filename, etc.{ "type": "Label", "text": "@{greeting}" }{ "type": "TextField", "hint": "@{placeholder}" } // 2. STYLE — colors, opacity, fontWeight, borders{ "type": "View", "background": "@{cardBg}", "opacity": "@{fadeAlpha}" }{ "type": "Label", "fontColor": "@{status}", "fontWeight": "@{emphasis}" } // 3. LAYOUT — sizes, visibility (String enum, NOT Bool){ "type": "View", "width": "@{panelWidth}" }{ "type": "View", "visibility": "@{panelVisibility}" } // "visible" | "invisible" | "gone" // 4. EVENT handlers — onClick, onTextChange, onValueChanged, onPageChanged, ...{ "type": "Button", "onClick": "@{onSubmit}" }{ "type": "TextField", "onTextChange": "@{onEmailChange}" } // 5. COLLECTION / RESOURCE — items, src, currentPage, ...{ "type": "Collection", "items": "@{bars}" }{ "type": "NetworkImage", "src": "@{coverUrl}" }{ "type": "TabView", "currentPage": "@{activeTab}" }How typing works per platform`@{count}` resolves to the `count` field on the ViewModel, but what 'typed binding' actually means differs per target. On Android, `jui build` emits Android DataBinding expressions (`@{data.count}`) and the kapt annotation processor type-checks them at `./gradlew assemble` — a Bool bound to `Label.text` fails the build. On Web, the converter emits `{data.count}` JSX and the `FooData` TypeScript interface generated alongside lets `tsc` catch type mismatches at `next build`. On iOS, bindings resolve at runtime via reflection — type errors surface as blank labels and console warnings, not build failures. `jui verify --fail-on-diff` is a structural-drift check (generated Layout vs spec); it does not validate binding types.
per-platform typing
// The SAME `@{count}` resolves differently per target. // ANDROID — compile-time check via DataBinding annotation processor.// Generated XML: android:text="@{data.count}"// Bool → Label.text fails at `./gradlew assemble`. // WEB — compile-time check via TypeScript + generated FooData interface.// Generated JSX: {data.count}// Bool → Label.text fails at `next build` (tsc error). // iOS — runtime reflection via SwiftJsonUI.// Generated SwiftUI: Text("\\(data.count)")// Bool → Label.text: blank label + console warning; build still succeeds. // `jui verify --fail-on-diff` is a STRUCTURAL drift check only// (generated Layout tree vs spec). It does NOT type-check bindings.Read-only bindings vs two-way form bindingsNon-input attributes — `Label.text`, `visibility`, `src`, colors, sizes, `items` — are read-only. The widget has no write-back path; the VM pushes, the view displays. Form inputs are different: `TextField`, `EditText`, `Input`, `CheckBox`, `Switch`, `Toggle`, `Slider`, `SelectBox`, `Segment`. The author writes `text: '@{email}'` + (optional) `onTextChange: '@{onEmailChange}'` — looking one-way — but the generator emits platform-native two-way machinery underneath: SwiftUI `$binding` on iOS, a `LaunchedEffect` that calls `viewModel.updateData(...)` on Android, a controlled input with auto-generated `onChange` on Web. That machinery is derivable only because the expression is a single flat identifier — a dot-path, `??`, or `!` on a two-way attribute fails the build as `binding-two-way-complex`. The VM field updates on every keystroke, before the author's handler runs. The handler is a notification hook, not a gate. To reject or transform user input, don't treat the callback as a guard — let the field update, then snap it back to the corrected value inside the handler; the same binding re-renders the input with the corrected text.
form inputs are two-way under the hood
// NON-INPUT attributes are read-only. The view has no write-back path.{ "type": "Label", "text": "@{greeting}" } // VM pushes; view displays. // FORM INPUTS emit platform-native TWO-WAY machinery automatically.// The author still writes the familiar pair — it LOOKS one-way:{ "type": "TextField", "text": "@{email}", "onTextChange": "@{onEmailChange}" // optional — fires AFTER the field updated} // Under the hood the generator emits:// iOS: TextField(..., text: $data.email) // SwiftUI $binding// Android: LaunchedEffect { viewModel.updateData(email=new) } // Compose LaunchedEffect// Web: <input value={data.email}// onChange={(e) => data.onEmailChange?.(e.target.value)} /> // The VM field updates on every keystroke — BEFORE the handler runs.// To reject / trim / transform, do NOT treat the callback as a gate —// let the field update, then snap it back to the corrected value:onEmailChange = (next: string) => { const cleaned = next.trim().slice(0, 128); this.updateData({ email: cleaned }); // same binding re-renders the input}; // Cross-platform callback signatures:// iOS: (oldValue: String, newValue: String) -> Void// Android: (newValue: String) -> Unit// Web: (newValue: string) => voidFocusing a field from the ViewModel — `<id>IsFocused`Give a `TextField` or `TextView` an `id` and the generator emits a two-way focus binding named `<id>IsFocused`. Set it true from the ViewModel to move keyboard focus to that field and raise the keyboard; it writes back false / true as the user blurs / focuses. It's the focus counterpart of the value binding — you wire no refs yourself. Available on iOS (SwiftUI), Android (Compose) and Web; UIKit is not covered. (TextView focus on iOS needs SwiftJsonUI 10.3.0+.)
<id>IsFocused — focus a field from the ViewModel
// 1. Give the field an id in the layout{ "type": "TextField", "id": "email", "text": "@{email}", "onTextChange": "@{onEmailChange}" } // 2. The generator adds a two-way focus field to the data model:// emailIsFocused: boolean // 3. Focus it from the ViewModel — moves focus AND raises the keyboardthis.updateData({ emailIsFocused: true }); // Reads back on blur / focus. Platform machinery:// iOS SwiftUI @FocusState (TextView needs SwiftJsonUI 10.3.0+)// Android Compose FocusRequester// Web ref + useEffect// UIKit not coveredNo expressions in `@{…}`The grammar inside `@{…}` is deliberately small: simple property (`@{displayName}`), dot-path (`@{user.profile.handle}`), array index (`@{items[0]}`), a default via `??` (`@{nickname ?? 'guest'}` — string / number / bool / `null` literal, at most one `??` per expression), boolean negation (`@{!isLoading}` — only as the whole value of a boolean-typed attribute such as `hidden` or `enabled`), action binding (`@{onSubmit}`), and cell-scoped `@{data.prop}` inside `cells/*.json`. That is the entire grammar — and it narrows further by context: two-way form attributes (`TextField.text`, `Switch.isOn`, …) accept only a single flat identifier (no dot-path, index, `??`, or `!`), and Embed `params` leaves accept a path but no `??` or `!`. Ternaries, comparisons (`===`, `<`, `>`), arithmetic (`+`, `*`, `%`), logical operators (`&&`, `||`), and function or method calls are not part of the grammar: at runtime the whole expression is treated as one unresolvable key (it renders as nothing), and the validator flags them as business-logic warnings during `jui build`. Violations of the canonical grammar itself — a second `??`, negation outside a boolean attribute, a complex two-way expression — fail `jui build` with precise rule ids (`binding-double-default`, `binding-negation-context`, `binding-two-way-complex`, …). Today that validator ships in the rjui toolchain only: builds that include the web target catch these at build time, while the iOS and Android generators accept malformed bindings and the error surfaces at runtime instead. The semantics (paths, indexes, defaults, negation, type coercion) are declared once in a shared `binding_semantics.json` with shared conformance vectors. If you need a derived value, compute it in the ViewModel as a getter and bind the getter: `get countLabel(): string { return this.count > 0 ? this.s('some') : this.s('none'); }` → `{ "text": "@{countLabel}" }`. Path-ascent (`@{^parentProp}`) and relative navigation (`@{../sibling}`) are not part of the grammar — pass values down explicitly through cell data blocks instead.
allowed grammar + rejected expressions
// ALLOWED grammar — exactly seven forms:{ "text": "@{displayName}" } // simple property{ "text": "@{user.profile.handle}" } // dot-path{ "text": "@{items[0]}" } // array index{ "text": "@{nickname ?? 'guest'}" } // ?? default (one literal, max once)// ! negation — prefix a flag, whole-value on a boolean attribute (hidden,// enabled). Literal form in the prose above: this very code block is a// validated JsonUI layout, and embedding the negation here would fail it.{ "onClick": "@{onSubmit}" } // action binding{ "text": "@{data.title}" } // cell-scoped (inside cells/*.json) // REJECTED — rjui's binding_validator.rb catches these with actionable warnings.{ "text": "@{count > 0 ? 'some' : 'none'}" } // ternary{ "text": "@{formatName(user)}" } // function call{ "text": "@{user.name.toUpperCase()}" } // method call{ "text": "@{a + b}" } // arithmetic{ "text": "@{isReady && isValid}" } // logical // Also not in the grammar:{ "text": "@{^parentProp}" } // no path-ascent{ "text": "@{../sibling}" } // no relative navigation // For derived values, compute in the ViewModel and bind the getter:// get countLabel(): string { return this.count > 0 ? this.s('some') : this.s('none'); }// { "text": "@{countLabel}" } // Note: validator runs on rjui only.// iOS and Android currently accept malformed bindings and fail at runtime.Keep reading
Why spec-firstThe spec is the contract. Binding discipline is one of several promises the contract makes./concepts/why-spec-first
ViewModel-owned stateThe layout has no state at all. Why this keeps cross-platform parity cheap./concepts/viewmodel-owned-state