← 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.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.
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.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.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.
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.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.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.
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.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 VMReused layout file yes yes yesParams (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