# State and data flow

> Derive-don't-store patterns, message dispatch, text editing paths, and effects as the only channel for side effects.

- 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

- `src/runtime/flow.zig`
- `src/runtime/core.zig`
- `src/runtime/effects.zig`
- `docs/src/app/state/layout.tsx`
- `skill-data/core/references/app-model-runtime.md`

---

---
title: "State and data flow"
description: "Derive-don't-store patterns, message dispatch, text editing paths, and effects as the only channel for side effects."
---

Native SDK apps keep a single `Model` as source of truth. Markup and Zig views never mutate it: they bind values and emit `Msg` values. `UiApp(Model, Msg)` owns the loop—`syncModel` → `applyMsg` (`update` or `update_fx`) → rebuild every open window’s tree—so every interaction, effect completion, and shell command converges on the same path.

## Data-flow contract

| Stage | Owner | Role |
| --- | --- | --- |
| View build | Markup / `view` / `window_view` | Read `*const Model`, bind values, attach typed handlers |
| Input / command / effect drain | Runtime + `UiApp` | Resolve handlers → `Msg`, never patch the model directly |
| `dispatch` | `UiApp` | Optional `Options.sync`, then `applyMsg`, then rebuild |
| `update` / `update_fx` | App | Mutate `Model`; side effects only through `*Effects` when using `update_fx` |
| Effects workers | `Effects(Msg)` | Off-thread I/O; post completions; model stays loop-thread only |

```mermaid
flowchart TB
  subgraph view_layer ["View (read-only)"]
    Markup[".native / CompiledMarkupView"]
    ZigView["view / window_view"]
  end
  subgraph ui_app ["UiApp loop"]
    Dispatch["dispatch"]
    Sync["syncModel + Options.sync"]
    Apply["applyMsg → update / update_fx"]
    Rebuild["rebuild + rebuildWindowSlots"]
  end
  subgraph effects ["Effects channel"]
    Fx["fx.spawn / fetch / file / clipboard / timer / audio"]
    Workers["worker threads"]
    Queue["bounded MPSC + wake"]
    Drain["drainEffects → takeMsg"]
  end
  Model[("Model")]
  Markup --> Dispatch
  ZigView --> Dispatch
  Dispatch --> Sync --> Apply --> Model
  Apply --> Rebuild
  Rebuild --> Markup
  Rebuild --> ZigView
  Apply --> Fx
  Fx --> Workers --> Queue --> Drain --> Apply
```

Markup cannot mutate state. A press, toggle, edit, scroll, or menu item only names a `Msg` tag (and optional payload). Logic lives in Zig `update`.

## Derive, don't store

The model holds **sources of truth only**: raw items, filters, draft text, effect keys, open flags. Counts, sums, filtered lists, and formatted display strings are **methods or arena-backed functions** evaluated at view build—not fields maintained in every `update` arm.

```zig
// Wrong: cached derivable, hand-maintained in update
visible_count: usize,
summary_storage: [64]u8,

// Right: sources + pure methods
pub fn visibleCount(model: *const Model) usize { ... }
pub fn visibleCents(model: *const Model) u64 { ... }
```

Derived numbers need no allocation; bind methods and compose in markup:

```html
<status-bar>{habit_count} habits · {totalDays} total days</status-bar>
```

### Format at view time

Store money as integer cents (or other source units). Format into the **build arena** inside an allocator-taking model method—the arena lasts exactly one view build:

```zig
pub fn visible(model: *const Model, arena: std.mem.Allocator) []const VisibleExpense {
    // filter + allocPrint into arena; return slice for for each="visible"
}

pub fn summary(model: *const Model, arena: std.mem.Allocator) []const u8 {
    return std.fmt.allocPrint(arena, "{d} expenses · {s} total", .{
        model.visibleCount(), formatCents(arena, model.visibleCents()),
    }) catch "";
}
```

Bindings and `for each` resolve **only Model decls** (fields and `pub fn` methods inside the struct). A file-scope helper next to `Model` is invisible to markup.

| Binding form | Resolves to |
| --- | --- |
| `{habit_count}` | Struct field |
| `{totalDays}` | `pub fn totalDays(m: *const Model) usize` |
| `{summary}` | `pub fn summary(m: *const Model, arena: Allocator) []const u8` |
| `for each="visible"` | Slice/array field, pub const slice, or `(*const Model)` / `(*const Model, Allocator)` method |
| Enum tags | Render as name; `set_filter:{f}` coerces back into enum payload |

For `<if test>`, prefer an explicit `bool` method (`test="{hasHabits}"`) over numeric truthiness.

<Note>
Parameterized queries (cards of column X) stay zero-argument bindings: one named model function per case, or a markup template arg—not free-form call expressions in markup.
</Note>

## Message dispatch

### Entry points → `Msg`

| Source | Mechanism |
| --- | --- |
| Pointer / keyboard on widgets | Tree handler table (`msgForPointer`, `msgForKeyboard`, …) |
| Markup `on-press`, `on-toggle`, `on-submit`, … | Tag or `tag:{payload}` coerced to `Msg` variant |
| `on-input` | `Msg` variant payload **must** be `canvas.TextInputEvent` |
| `on-scroll` | Payload `canvas.ScrollState` (offset, extents) |
| `on-resize` (split) | Payload `f32` fraction already applied by the runtime |
| Shell menus / shortcuts / tray | `Options.on_command` maps command name → `?Msg` |
| App-level unclaimed keys | `Options.on_key` → `?Msg` |
| Effect completions | Stored constructors (`lineMsg`, `exitMsg`, `responseMsg`, …) |

### `dispatch` sequence

`UiApp.dispatch` is the single apply+rebuild funnel:

1. Bind the effects channel to the live runtime.
2. **`syncModel`** — if `Options.sync` is set, call it with the current layout so sliders / similar runtime-owned values land in the model before `update`.
3. **`applyMsg`** — `update_fx(model, msg, &effects)` or `update(model, msg)`.
4. Publish audio snapshot fields for automation.
5. If installed: **rebuild** main canvas and every secondary window slot (one model generation across windows).

Handler and update errors **degrade** in production: recorded in a bounded dispatch-error ring (`max_dispatch_errors = 16`), visible in traces and automation snapshots; the app keeps running. Test harnesses may set `DispatchErrorPolicy.propagate` so capacity errors fail tests.

### Event attributes (markup)

| Attribute | Payload shape | Notes |
| --- | --- | --- |
| `on-press` / `on-toggle` / `on-hold` / `on-dismiss` | Plain tag or `tag:{payload}` | Nested pressables: deepest wins |
| `on-submit` | Tag / payload | Enter on field; primary+Enter on textarea; primary action on list rows with submit |
| `on-input` | `TextInputEvent` only | Teaching error if payload type mismatches |
| `on-scroll` | `ScrollState` | Echo offset through `value` |
| `on-resize` | `f32` | Split divider; store and echo through `value` |
| `on-reach-end` | Plain `Msg` | Infinite-fetch; hysteresis 1.0 / 1.5 viewports |
| `on-change` (markup slider) | Plain `Msg` (no value) | Use `Options.sync` or Zig `on_value` for the applied f32 |

Press rule: plain text/icons fall through to the nearest pressable ancestor; editable fields, scrolls, and modal surfaces claim their own hits.

## Text editing paths

### Elm-style mirror (`TextBuffer`)

The model applies every edit and owns the text. The runtime keeps caret/selection while the bound source text matches; a source-side clear (e.g. submit) wins.

```html
<text-field text="{draft}" placeholder="New task…" on-input="draft_edit" on-submit="add" grow="1" />
```

```zig
draft_buffer: canvas.TextBuffer(64) = .{},
pub fn draft(model: *const Model) []const u8 {
    return model.draft_buffer.text();
}
// update:
.draft_edit => |edit| model.draft_buffer.apply(edit),
.add => {
    model.addTask(model.draft_buffer.text());
    model.draft_buffer.clear();
},
```

Bind the **function** (`text="{draft}"`), never a `TextBuffer` field—the validator treats direct buffer binding as a teaching error. Live reference: `examples/ui-inbox`.

### Runtime clipboard and selection

- Editable text: cmd/ctrl+C/X/V is runtime-owned. Cut/paste arrive as ordinary `TextInputEvent`s (`insert_text`, etc.); the mirror stays consistent with no extra code.
- Paste over capacity sets `edit_truncated` on the keyboard event and `TextBuffer.truncated` on the model side.
- Static text leaves support drag-select + copy per widget; selection survives rebuilds while text bytes are unchanged.
- Programmatic clipboard from `update`: `fx.writeClipboard` / `fx.readClipboard` on the effects channel—not `pbcopy` / `xclip`.

`canvas.TextBuffer(capacity).apply` routes through `applyTextInputEvent` (insert, delete, word nav, clear, composition). Payload lifetimes for effect lines match text events: **copy what you keep** before the next drain.

## Echo runtime-applied values

Some values are applied by the runtime first, then delivered on a `Msg`. Store them and **echo the same value** through the element’s binding so reconcile treats the source as unchanged and rebuilds never fight live interaction.

| Control | Msg payload | Echo through |
| --- | --- | --- |
| `split` | `f32` fraction (`on-resize`) | `value="{sidebar_split}"` |
| `scroll` | `ScrollState` (`on-scroll`) | `value` offset |
| Markup `slider` `on-change` | Plain tag | `Options.sync` (or Zig `on_value`) |

```zig
.sidebar_resized => |fraction| model.sidebar_split = fraction,
```

Windowed virtual lists are the exception: the runtime re-derives the visible window; do **not** echo offsets into `virtualList`’s `value`.

## Effects: only channel for side effects

Anything that leaves the process—subprocesses, HTTP, files, clipboard, timers, audio—goes through `Effects(Msg)`. Views never spawn. Set **exactly one** of `update` and `update_fx` (asserted at init).

```zig
const App = native_sdk.UiApp(Model, Msg);
const Effects = App.Effects;

pub fn update(model: *Model, msg: Msg, fx: *Effects) void { ... }
// options: .update_fx = update,
```

### Boot effects

`Options.init_fx` runs **once** on the installing frame, after the channel is bound and **before** the first view build. Prefer it for open-with-fetch. Do not unguardedly spawn from `on_frame` (it re-fires every frame).

### Channel surface

| API | Terminal / stream Msgs | Notes |
| --- | --- | --- |
| `fx.spawn` | `on_line` + always one `on_exit` | `.lines` (default) or `.collect` whole stdout |
| `fx.fetch` | one `on_response`; optional `on_line` if `.stream` | Shared key space and slots with spawn |
| `fx.writeFile` / `fx.readFile` | one `on_result` | Over-bound writes **rejected** (never partial) |
| `fx.writeClipboard` / `fx.readClipboard` | one `on_result` | Sync on loop thread; result still a Msg |
| `fx.startTimer` / `fx.cancelTimer` | `on_fire` | Separate table (`max_effect_timers = 16`) and key namespace |
| `fx.playAudio` + transport | `on_event` | Separate audio key namespace; one player |
| `fx.registerImage*` / `unregisterImage` | (sync, no Msg) | Image registry bound on the channel |

Keys are caller-chosen `u64`s stored in the model—no handles. `fx.cancel(key)` guarantees no further stream lines and exactly one terminal with `.cancelled` / cancelled outcome.

### Hard limits (loud, never silent)

| Limit | Default |
| --- | --- |
| In-flight effects (spawn/fetch/file/clipboard) | `max_effects = 16` |
| Completion queue depth | `max_effect_queue_entries = 64` |
| Line bytes (default / ceiling) | 4 KiB / 256 KiB |
| Collect stdout | 512 KiB |
| Fetch body | 256 KiB |
| File path / bytes | 1 KiB / 1 MiB |
| Clipboard | platform max (effects use same bound) |

Overflow surfaces as `.rejected` terminals, `truncated` / `dropped_before` / `dropped_lines` flags—not silent drops without accounting.

### Drain path

Workers never touch the model. They post fixed-size records, call `PlatformServices.wake_fn`, and the loop thread runs `drainEffects`: `while (effects.takeMsg()) applyMsg` then one rebuild. Host-pumped embeds also drain on presented frames. In tests: set `effects.executor = .fake`, `feedLine` / `feedExit` / `feedResponse`, then `runtime.dispatchPlatformEvent(app, .wake)`.

Drain-scratch rule: `EffectLine.line`, `EffectResponse.body`, file/clipboard result bytes, and collect `output` / `stderr_tail` die after the current `update` call—**copy into the model**.

Live patterns: `examples/effects-probe`; evals judge derive-don't-store + effect keys on process-monitor style apps.

## Time as a testable seam

`update` does not receive `std.Io`. Use the runtime clock facade—not raw `clock_gettime`:

```zig
native_sdk.nowMs()                 // wall ms
native_sdk.monotonicMs()           // durations
// Prefer model-owned seam for testable logic:
clock: native_sdk.Clock = .system,
// tests: TestClock.advanceMs / setWallMs
```

Wall time for “what time is it?”; monotonic for “how long?”. Do not subtract wall timestamps for durations. Markup `date()` / `time()` take model-held unix seconds—`now()` in expressions is a teaching error (clock reads are effects).

## Async state in the model

In-flight work is ordinary model state: `loading: bool`, effect keys, line rings, status enums driven by exit/response outcomes. The UI binds those fields/methods; it never observes worker threads. Cancel is model-driven (`fx.cancel(key)` from a cancel `Msg`).

## Verification

| Check | Signal |
| --- | --- |
| Unit dispatch | Build markup/Zig tree → `msgFor*` → `update` → rebuild → assert model + stable ids |
| Effects | Fake executor + `.wake` drain; assert pending requests and applied Msgs |
| Live UI | `native build -Dautomation=true` + `native automate assert` / snapshot greps |
| Binding contract | `native check` / model-contract after `native test` |
| Teaching errors | Direct `TextBuffer` bind, wrong `on-input` payload, file-scope “model” fns |

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, UiApp options, hot reload boundaries, and markup vs compiled views.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, bindings, expressions, and message attribute grammar.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, cancellation, worker wakes, capacities, and frame limits.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services and effect-channel access boundaries.
  </Card>
  <Card title="Testing" href="/testing">
    Full-loop UI tests, effect tests, and headless truth drivers.
  </Card>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Single command routing into on_command and Msg.
  </Card>
</CardGroup>
