JsonUI
← ConceptsScreen compositionThree ways to put one Layout JSON inside another: `include` lets the codegen inline a sub-layout under the same ViewModel; `TabView.tabs[].include` runs multiple layouts under one parent VM; `Embed` hosts a sub-screen with its own ViewModel. The choice changes how state, lifecycle, and navigation work — pick the wrong one and you fight the framework. Here is the trade-off and the decision rule.~7 min read
Why three primitivesLayout reuse and screen reuse look the same on the surface — both put one file inside another — but they answer different questions. Reusing a *layout* is about avoiding duplicate JSON; the parent screen still owns all the state. Reusing a *screen* is about hosting a fully independent unit (its own data flow, its own lifecycle) inside a host. JsonUI keeps these separate so you don't accidentally couple two screens together, or accidentally split state across what should be one screen.
include — codegen inlines, shared VM`include` is a build-time directive. The codegen reads the referenced layout file, replaces the `include` node with its tree, and emits one component. Every `@{...}` binding in the included tree resolves against the parent ViewModel — there is no second VM, no separate lifecycle, no separate state. This is the right tool when two screens have an identical region (a header, a card, a stat block) and you want one source of truth for the JSON.
include (codegen inlines)
// recent_activity.json — a stand-alone sub-layout
{
"type": "View",
"orientation": "vertical",
"child": [
{ "type": "Label", "text": "@{title}", "fontSize": 20, "fontWeight": "bold" },
{ "type": "Label", "text": "@{subtitle}", "fontSize": 14 }
]
}
 
// home.json — pulls recent_activity in by reference
{
"type": "View",
"orientation": "vertical",
"child": [
{ "include": "recent_activity" },
{ "type": "Button", "text": "See all", "onClick": "@{onSeeAllTap}" }
]
}
 
// Codegen expands `include` inline — the resulting View binds to the
// PARENT VM's `@{title}` / `@{subtitle}` / `@{onSeeAllTap}`. There is no
// second VM. The included file is a code-reuse artifact.
If the included tree references a binding the parent VM doesn't expose, codegen fails at build time. Useful: drift surfaces immediately. Limiting: you can't reuse a region whose bindings have meaningful conflict with the parent.
TabView.tabs[].include — one VM, multiple tab layoutsTabView reuses the `include` mechanism per tab, but adds a persistent tab bar and an `@{selectedIndex}` binding. The host VM owns the selected index and every tab's state. Tab switches are cheap — no VM teardown, no re-fetch — because all tabs co-exist under the same VM. Use this when the user perceives the tabs as different *views* of the same screen (Home/Search/Profile in a single app shell), not as different screens.
TabView.tabs[].include
{
"type": "TabView",
"selectedIndex": "@{activeTab}",
"tabs": [
{ "label": "@string/home", "icon": "house", "include": "home" },
{ "label": "@string/search", "icon": "magnifyingglass","include": "search" },
{ "label": "@string/profile", "icon": "person", "include": "profile" }
]
}
 
// One ViewModel drives all three tabs. `@{activeTab}` is owned by that
// VM; `@string/home` resolves against the same StringManager. The tabs
// share state — switching tabs does NOT re-create their state.
Persistent state across tab switches is the headline feature. If the user expects state to reset on tab change, TabView is the wrong tool — that's a navigation pattern, not composition.
Embed — child owns its own ViewModel`Embed` is the first composition primitive that creates a second ViewModel. The parent declares `structure.embeds[]` and places an `Embed` node in its Layout; at runtime the framework instantiates the child screen — its VM, its bindings, its lifecycle — inside that region. The child does not know it is embedded, which means the same screen can run stand-alone, embedded in a master/detail, and embedded in a dashboard without code change. Cross-screen communication is `params` (parent → child, push semantics) and `events` (child → parent, callback semantics) only — there is no shared state.
Embed (child owns its own VM)
// Parent spec — declares the embed in structure.embeds[]
{
"metadata": { "name": "OrdersDashboard", "layoutFile": "orders/dashboard" },
"structure": {
"embeds": [
{
"regionId": "detailPane",
"screen": "order_detail",
"params": { "orderId": "@{selectedOrderId}" },
"events": { "onOrderUpdated": "handleOrderUpdated" },
"navigationMode": "delegate"
}
]
}
}
 
// Parent layout — places the Embed in the visual tree
{
"type": "View",
"orientation": "horizontal",
"child": [
{ "type": "Collection", "id": "orderList", "weight": 1, "items": "@{orders}" },
{ "type": "Embed", "id": "detailPane", "screen": "order_detail",
"params": { "orderId": "@{selectedOrderId}" }, "weight": 2 }
]
}
 
// order_detail.json + OrderDetailViewModel are UNCHANGED. The embedded
// screen does not know it is embedded — it runs the same way it would
// as a stand-alone screen.
Independent lifecycle is the whole point. Each embed mounts and unmounts on its own — useful for tablet master/detail where the detail pane is conceptually a separate screen and may even need to refresh independently of the master.
Side-by-sideThe columns below are the same questions you ask when picking a tool. Read the table top to bottom: the further down the differences accumulate, the more `Embed` looks like a real screen rather than a layout fragment.
comparison.txt
include TabView.tabs[].include Embed
────────────────────────── ─────────── ──────────────────────── ──────────
VM ownership shared shared (one parent VM) child has its own VM
Reused layout file yes yes yes
Params (parent → child) n/a (1 VM) n/a (1 VM) params: { ... }
Events (child → parent) n/a (1 VM) n/a (1 VM) events: { onX → handler }
Independent lifecycle no no (tabs co-exist) yes (mount / unmount per id)
Multiple instances rare no (one per tab) yes (each gets its own VM)
Navigation parent parent parent (delegate, bounded pop)
Codegen output inline tree inline tree per tab <EmbedContainer screen=…>
Embed lifecycle — params, events, navigationFour contracts are worth memorizing when adopting Embed:• `params` is a tree: keys are camelCase at every level, intermediate nodes are literal objects, and leaves are scalar literals or `@{varName}` bindings against the parent VM (bindings are leaf-only; arrays are not supported). The child VM receives them via optional `applyInitParams(_:)`; VMs that don't implement it ignore params silently. A callback-typed parent property passed as a leaf binding is the canonical escape hatch for navigating out of an isolated embed.• `events` maps `on[A-Z]...` names from the child to parent VM handler names. The child emits via the library-provided `emit(name, payload)` helper — no spec declaration on the child side.• `navigationMode`: `"delegate"` (default) shares the parent's NavController/Router — the child's `push` bubbles to the parent so a new screen takes the whole window, while `pop` / `dismiss` / `navigateBack` are bounded at the embed. `"isolated"` (SwiftJsonUI 10.5.0+ / KotlinJsonUI 2.12.0+) gives the embed a private nav stack: push stays inside the embed, pop stops at the embed stack's root, and present-type transitions (sheet / dialog / dismiss) are forbidden in the embedded screen. In both modes the child can never close its own host.• Multiple embeds of the same screen each get their own VM, keyed by `regionId`. On Android this is enforced by a `remember(regionId)` ViewModelStoreOwner inside `EmbedContainer`; iOS and Web get it for free from their respective view systems.
Picking the right primitiveStart from the question 'does this region need its own state and lifecycle?' — that's the dividing line. If no, you almost always want `include` (or `TabView.tabs[].include` if you also need a persistent tab bar). If yes, reach for `Embed`.• `include` — same screen reuses a region, or two unrelated screens share a region whose bindings happen to match the parent VM. No new VM, no lifecycle, no state isolation.• `TabView.tabs[].include` — tabs feel like *views* of one screen (the user expects state to persist when switching). One VM drives every tab.• `Embed` — tablet master/detail (detail is a real screen), dashboard panel with independent fetch, a workspace pane that already runs as a stand-alone screen and should be reusable inside larger surfaces.
Keep going
ViewModel-owned stateHow a ViewModel keeps its own state — the contract every embedded screen relies on./concepts/viewmodel-owned-state
Embed — Component referenceAttribute-by-attribute reference for `Embed`: screen, params, events, navigationMode./reference/components/embed