App Model
A Native SDK app is one loop with four parts:
- Model — a plain data structure holding all app state.
- Msg — a tagged union of everything that can happen.
- update(model, msg) — the only place state changes.
- View — a Native markup file (
.native, or a Zig view function) that derives the UI from the model.
The runtime manages windows, rendering, input dispatch, timers, accessibility, and hot reload. A widget dispatches its bound message to update; the runtime then derives the view from the committed model and renders the changes.
Both TypeScript and Zig cores use this loop. The default project has src/core.ts, compiled to native code at build time. A zig-core project has src/main.zig. The wiring sections below show the runtime setup that the build generates for TypeScript apps and that a Zig app can configure directly. See TypeScript Cores for the TypeScript contract.
The loop in full
A minimal counter declares state, messages, and a pure update:
The view in src/app.native binds the model and names the messages:
Markup can never mutate state. {count} is a read; on-press="increment" names a Msg variant (in a TypeScript core, the arm's kind). Every state change flows through update, which makes the app's behavior testable as a plain function — the Zig template's generated src/tests.zig drives it with no GUI at all, and a TypeScript core runs under node the same way (native dev --core).
Wiring
native_sdk.UiApp(Model, Msg) ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the compiled core's model) — but it is ordinary code you can own any time. From the Zig template's main:
create requires every Model field to carry a default; the model starts as .{} and boot state is assigned through the returned pointer. The scene declares the native window and its GPU surface view — see Windows and Native Surfaces for multi-view scenes.
Rebuilds and widget identity
After update, the runtime derives the view from the committed model. Reconciliation follows these rules:
- Widget identity is structural. A widget keeps its id across rebuilds, reorders, and hot reloads, so engine-owned state — scroll offsets, text carets, focus — survives. List items carry
key(orglobal-keyfor items that move between containers) to keep identity through reorders. Unkeyed same-kind siblings take positional identity (sibling index), so an<if>that inserts or removes an earlier same-kind sibling re-disambiguates the trailing ones — engine-owned state like carets and scroll can hop; keyed items and keyed ancestors hold identity. - The source wins. Engine-retained state (a scroll offset, a toggle) survives rebuilds until the model asserts a different value; then the model's value applies. This is why controlled patterns echo runtime-applied values back through the model — see State & Data Flow.
- Update errors are recorded. A returned
updateerror is caught and recorded in a bounded error ring (visible in automation snapshots asdispatch_errors=), and the app keeps running.
Hot reload in development
With watch_path set, the runtime watches the .native file while the app runs. Edits preserve model state and widget identity. Parse failures keep the last good view on screen and record a diagnostic (app_state.markup_diagnostic carries line, column, and message).
Compile the markup for release
In release builds the markup compiles at comptime — no parser in the binary, and markup or binding mistakes become compile errors with line and column:
Both engines produce the identical widget tree — same structural ids, same typed handler table — so tests, automation scripts, and goldens hold across dev and release.
Hybrid views: a Zig root composing markup fragments
Compiled markup views compose the other way too: a hand-written Zig builder root can build markup fragments as ordinary children. This is the pattern for any UI that mixes custom Zig panes with declarative markup — the root places what the closed grammar cannot express (a scaled ui.paragraph display block, a .band-series ui.chart, per-row native context menus), and the markup keeps everything it can:
examples/calculator is the smallest live reference (CompiledKeypadView.build(ui, model) inside a hand-written root); deck uses the same shape. Widget ids, handlers, and dispatch are identical to a pure-markup tree, so tests and automation address the fragment's widgets the usual way.
A Zig-root app keeps dev-time hot reload for its embedded fragments too: build with UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev_markup_reload }) and pass .markup = .{ .source = ..., .watch_path = "src/header.native", .io = init.io } gated on builtin.mode == .Debug (null otherwise) — the wiring in examples/notes. Debug builds then reload edits to the watched file in place; release builds compile the runtime engine out entirely.
Side effects
update stays pure by routing anything that leaves the model — subprocesses, HTTP, file persistence, timers, clipboard, desktop notifications — through the effects channel, and routed results come back as ordinary messages. In a TypeScript core, effects are Cmd data returned from update and recurring timers are declared Sub data — see TypeScript Cores: Effects. In a Zig core, declare .update_fx instead of .update and spawn from message arms; boot-time work goes in .init_fx, which runs exactly once before the first paint. See Native UI: Effects.
Dropping down
UiApp is a layer over the lower-level App/Runtime pair, which any app can use directly — for custom lifecycle callbacks, imperative window and view management, or embedding web content. The App & Runtime reference documents that layer, and Embedded App covers driving the runtime from an existing host (including iOS and Android).