# App model

> Model, Msg, and update loop wiring, hot reload boundaries, and how markup binds values without mutating state.

- Repository: vercel-labs/native
- GitHub: https://github.com/vercel-labs/native
- Human docs: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms-full.txt

## Source Files

- `skill-data/core/references/app-model-runtime.md`
- `src/runtime/core.zig`
- `src/runtime/flow.zig`
- `src/runtime/api.zig`
- `build/app.zig`

---

---
title: "App model"
description: "Model, Msg, and update loop wiring, hot reload boundaries, and how markup binds values without mutating state."
---

`native_sdk.UiApp(Model, Msg)` (and `UiAppWithFeatures`) owns the declarative canvas loop: a heap-resident `Model`, a tagged-union `Msg`, exactly one of `update` / `update_fx`, and a view from either a Zig builder (`Options.view`) or runtime-parsed `.native` markup (`Options.markup`). It implements `native_sdk.App` via `app()`, drives install / rebuild / typed dispatch on `Runtime`, and keeps side effects on the effects channel — not in the view.

## Loop shape

| Piece | Type / surface | Role |
| --- | --- | --- |
| **Model** | Plain Zig struct | All app state. Fields used with `create` need defaults. |
| **Msg** | `union(enum)` | Every event that can change state (press, input, command, effect result, chrome, …). |
| **update** | `fn (*Model, Msg) void` **or** `update_fx` | Sole state mutation path. Exactly one of the two is set. |
| **View** | `view` and/or `markup` | Derives `canvas.Ui(Msg)` nodes from `*const Model`. Never mutates the model. |

Runtime owns window creation, GPU presentation, resize, pointer/keyboard dispatch through the tree handler table, timers, accessibility, and markup watch polling. Input hits a widget → bound `Msg` → `applyMsg` → rebuild → repaint.

```mermaid
flowchart LR
  subgraph inputs [Input and host]
    Ptr[Pointer / keyboard]
    Cmd[Menus / shortcuts / tray]
    Fx[Effect completions]
    Watch[Markup watch timer]
  end
  subgraph uiapp [UiApp]
    Disp["dispatch / drainEffects"]
    Upd["update / update_fx"]
    Model[(Model)]
    Build["buildViewNode"]
    Tree["Ui.Tree + handler table"]
  end
  subgraph runtime [Runtime]
    Layout[layout + reconcile]
    Present[GPU / pixels]
  end
  Ptr --> Disp
  Cmd --> Disp
  Fx --> Disp
  Watch --> Build
  Disp --> Upd --> Model
  Model --> Build --> Tree --> Layout --> Present
```

## Wiring

`Options` asserts at init:

- At least one of `view` or `markup`.
- Exactly one of `update` or `update_fx`.
- If `windows_fn` is set, `window_view` is required.
- If `UiAppFeatures.runtime_markup` is `false`, `markup` must be null.

Construction paths:

| API | Model lifecycle |
| --- | --- |
| `UiApp.init(allocator, model, options)` | Caller supplies the initial model value. |
| `UiApp.create(allocator, options)` | Allocates the multi-MB app on the heap; model starts as `.{}` (every field needs a default); assign boot state through the pointer, then `destroy`. |
| `initInPlace` | Builds everything except the model; assign `app.model` immediately after. |

`app()` returns `native_sdk.App` with `scene_fn`, `event_fn`, `stop_fn`, and `replay_fn`. Stop tears down the effects channel while the platform is still alive; main’s deferred `deinit` only frees app memory afterward.

```zig
const dev = @import("builtin").mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev });
const Compiled = canvas.CompiledMarkupView(Model, Msg, @embedFile("app.native"));

const app_state = try App.create(std.heap.page_allocator, .{
    .name = "my_app",
    .scene = shell_scene,           // one window, one gpu_surface
    .canvas_label = "main-canvas",  // must match the surface view label
    .update = update,
    .view = Compiled.build,
    .markup = if (dev) .{
        .source = @embedFile("app.native"),
        .watch_path = "src/app.native",
        .io = init.io,
    } else null,
});
defer app_state.destroy();
// optional boot: app_state.model.count = 0;
try runner.runWithOptions(app_state.app(), .{ ... }, init);
```

Live reference: `examples/ui-inbox` (markup + Zig model), `examples/notes` (`init_fx`, commands, tokens), `examples/split-collapse` (layout tweens / hybrid view).

### Common `Options` fields

<ParamField body="name" type="[]const u8" required>
App name for traces and automation snapshots.
</ParamField>

<ParamField body="scene" type="ShellConfig" required>
Shell windows and views; canvas apps use a `gpu_surface` whose label equals `canvas_label`.
</ParamField>

<ParamField body="canvas_label" type="[]const u8" required>
Primary canvas view label for install, rebuild, and input routing.
</ParamField>

<ParamField body="update" type="fn (*Model, Msg) void">
Pure update. Mutually exclusive with `update_fx`.
</ParamField>

<ParamField body="update_fx" type="fn (*Model, Msg, *Effects) void">
Effects-capable update; spawn/cancel only from update arms, never from the view.
</ParamField>

<ParamField body="init_fx" type="fn (*Model, *Effects) void">
Runs exactly once on the installing frame, after the effects channel is bound and before the first view build.
</ParamField>

<ParamField body="view" type="fn (*Ui, *const Model) Node">
Zig builder or `CompiledMarkupView(...).build`. Used alone in release, or until watched markup diverges when both `view` and `markup` are set.
</ParamField>

<ParamField body="markup" type="MarkupOptions">
Runtime parser/interpreter: `source`, optional `sources` import closure, optional `watch_path` + `io` for Debug hot reload.
</ParamField>

<ParamField body="on_command" type="fn (name) ?Msg">
Maps shell command events (menu, shortcut, tray, toolbar, bridge) into messages.
</ParamField>

<ParamField body="on_chrome" type="fn (WindowChrome) ?Msg">
Hidden-inset titlebar overlay geometry into the model (traffic-light insets, band height).
</ParamField>

<ParamField body="windows_fn" type="fn (*const Model, *WindowsScratch) []const WindowDescriptor">
Model-declared secondary windows; presence is visibility. Pair with `window_view`. Cap: `max_ui_windows`.
</ParamField>

## Dispatch and rebuild

`dispatch(runtime, window_id, msg)`:

1. Binds the effects channel.
2. Optional `sync` reads runtime-owned layout state into the model.
3. `applyMsg` runs `update` or `update_fx`.
4. If not yet installed, returns without rebuilding (appearance/chrome can land pre-frame).
5. Rebuilds the main canvas tree and secondary window slots.

Effect completions drain on `.effects_wake` and each presented frame: each queued result becomes a `Msg`, then a single rebuild if any ran. Input mapping goes through the retained tree (`msgForPointerClick`, `msgForTextEdit`, `msgForKeyboard`, `msgForScroll`, `msgForDismiss`, `msgForResize`, `msgForChange`, `msgForContextMenu`, …). Optimistic runtime echoes (slider value, dismiss hide, text clear) apply immediately; the model owns the value after the mapped `Msg` and the next rebuild is source of truth.

Rebuild uses a two-arena swap: the previous tree’s arena stays alive until the following rebuild so the handler table remains valid between events. Virtual lists may do a second build pass in the same rebuild when window coverage is under-guessed.

## Markup binds values; it never mutates state

`.native` files are compiled (comptime or runtime) into the same `canvas.Ui(Msg)` tree a hand-written `view` would build: structural ids, flex layout, typed handlers.

| Markup form | Meaning |
| --- | --- |
| `{count}`, `{t.title}`, `{draft}` | Read model (or loop-item) fields / methods into attributes or text. |
| `on-press="increment"` | Names a unit `Msg` variant. |
| `on-press="set_filter:{f}"` | Payload variant with bound expression. |
| `on-input="draft_edit"` | Text edits map to a `Msg` carrying `TextInputEvent`; the model applies them. |
| `on-toggle="toggle:{t.id}"` | Control change → message with id payload. |
| `selected="{f == filter}"`, `checked="{t.done}"` | Controlled display from model; engine state survives until the model asserts a different value. |
| `key="id"` on `<for>` | Stable structural identity across reorder/filter. |

Markup cannot assign to the model. Every mutation is a named `Msg` handled in `update`. Variants never bound from markup can declare `pub const view_unbound = .{"chrome_changed"}` so dead-state lint skips them.

```html
<text-field text="{draft}" placeholder="New task…" on-input="draft_edit" on-submit="add" grow="1" />
<button variant="primary" on-press="add">Add task</button>
<for each="visible" key="id" as="t">
  <checkbox checked="{t.done}" on-toggle="toggle:{t.id}" label="Done" />
  <text grow="1">{t.title}</text>
</for>
```

## Hot reload boundaries

| Mode | Mechanism | What reloads | What is preserved |
| --- | --- | --- | --- |
| **Root markup watch** | `Options.markup.watch_path` + `io`; Debug + `runtime_markup` | Watched file and its import closure via timer poll | Model state; last good view on parse failure |
| **Fragment watch** | `fragment_watch` with `CompiledX.fragment("path")` | Registered fragment closures (budget `max_watched_fragments` = 16) | Same degrade family; revert-to-baseline restores compiled path |
| **Release / non-Debug** | Watch machinery compiles out | Nothing on disk | Comptime `CompiledMarkupView` only |

When both `view` and `markup` are set, the compiled view runs until the watched file first diverges from the embedded baseline; then the interpreter takes over. Failed parses keep the last good document, set `app_state.markup_diagnostic` (line, column, message), and avoid teaching the same bad save every poll (hash-gated). Successful reload rebuilds without resetting the model.

Zig logic (`Model`, `Msg`, `update`) is not hot-reloaded — only markup sources under watch. Mobile embed uses the compiled view; markup hot reload is desktop Debug.

## Dev vs release engines

```zig
const dev_markup_reload = builtin.mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{
    .runtime_markup = dev_markup_reload,
});
```

| Build | View path | Parser in binary |
| --- | --- | --- |
| Debug with `markup` + `watch_path` | Runtime interpreter after first disk divergence | Yes |
| Release with `CompiledMarkupView.build` | Comptime-compiled builder | No (`runtime_markup = false`) |

Both engines produce the same structural ids and handler table so tests, automation, and goldens hold across modes. Hybrid roots compose fragments as ordinary children: `CompiledHeaderView.build(ui, model)` inside a Zig `rootView` (see `examples/calculator`, `examples/notes`).

## Effects and purity

`update` stays free of async I/O. With `update_fx` / `init_fx`, use `Effects` (`fx.spawn`, `fx.fetch`, `fx.readFile` / `writeFile`, `fx.startTimer`, `fx.cancel`, audio helpers). Completions re-enter as ordinary `Msg`s. Views never spawn effects. Boot work belongs in `init_fx` (once, pre-first paint), not a guarded `on_frame`. See [Runtime and effects](/runtime-effects) and [State and data flow](/state-data-flow).

## Rebuild identity and controlled state

- **Structural identity**: widget ids survive rebuilds, reorders, and hot reloads. List items use `key` (or `global-key` across containers). Unkeyed same-kind siblings use positional identity — an `<if>` that inserts earlier siblings can re-disambiguate trailing ones.
- **Source wins**: engine-retained scroll/caret/focus survive until the model asserts a different controlled value; then the model applies.
- **Windows**: `windows_fn` presence is visibility; user close dispatches `on_close`; keeping the window in the declared set re-creates it on the next rebuild.

## Lower-level `App` / `Runtime`

`UiApp` is a layer over `App` + `Runtime` (`src/runtime/api.zig`, `flow.zig`, `core.zig`). WebView-first apps return `native_sdk.App` with `source` / `source_fn` / lifecycle hooks without a Model/Msg loop (e.g. `examples/hello`). Embed hosts pump frames through the mobile C ABI (`build/app.zig` `addMobileLib`, `native_sdk_app_*` symbols). Custom lifecycle, imperative windows, or bridge-heavy shells use the runtime surface directly.

## Constraints and failure modes

| Constraint / signal | Behavior |
| --- | --- |
| `create` without Model field defaults | Comptime error naming the field. |
| Both or neither of `update` / `update_fx` | Assert at `assertOptions`. |
| Markup parse error under watch | Last good view kept; `markup_diagnostic` set. |
| Fragment count &gt; 16 | Extra fragments stay compiled-only (warning). |
| Pre-install `dispatch` | Model updates; no rebuild until first GPU frame install. |
| Platform stop | `stop_fn` deinit effects while services are live. |

## Next

<CardGroup>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don’t-store, message dispatch paths, text editing, and effects as the only side-effect channel.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, flex layout, bindings, expressions, and LSP support for `.native` views.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, cancellation, worker wakes, and frame limits behind `update_fx`.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    `native init` / `native dev` scaffold: first Model, Msg, update, and live window.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Shell scenes, multi-window reconciliation, and GPU surface presentation.
  </Card>
  <Card title="Embedded app" href="/embed">
    C embed ABI and host-driven frames for the same UiApp model on mobile.
  </Card>
</CardGroup>
