---
title: "State & Data Flow"
url: "https://native-sdk.dev/docs/state"
docs_index: /llms.txt
lastUpdated: 2026-10-09
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Store source data in the model, such as items, the current filter, and draft text. Derive counts, filtered lists, and formatted strings from that state when rendering the view. The patterns below apply to TypeScript and Zig cores; samples show both languages where their APIs differ.

## Pick one canonical store

For small in-memory apps, the Model owns the domain data and [model persistence](/docs/persistence) snapshots it. For relational apps, [SQLite](/docs/sqlite) owns the domain data; the Model holds view state plus the row pages most recently delivered by a query. Writes go out as one atomic `Cmd.db.exec`, and reads return as later Msg values—never as a database handle or a synchronous call inside `update`. Derived UI values still come from the Model's current rows, so the rule below does not change.

Secrets are the exception: tokens and passwords never belong in the Model, even temporarily. Store them with [`Cmd.credentials.set`](/docs/capabilities), retrieve them as an effect result, and consume the returned Msg bytes immediately to construct the next command (for example, an authenticated fetch) while returning a Model that does not retain them. That keeps secrets out of model snapshots, state fingerprints, and ordinary view data. Session recording redacts successful credential reads and replay supplies only a same-length placeholder.

## Derive, don't store

A cached derivable must be re-maintained in every `update` arm and goes stale the moment one is missed; a derived function cannot go stale.

```ts
// WRONG: derived state cached in the model, maintained by hand in update()
//   readonly visibleCount: number;

// RIGHT: the model keeps the sources; exported single-model helpers derive
// per rebuild and join the binding surface ({visible_count}, {visible_cents})
export function visibleCount(model: Model): number {
  return model.expenses.filter((e) => matches(model, e)).length;
}
```

```zig
// WRONG: derived state cached in the model, maintained by hand in update()
visible_count: usize,
summary_storage: [64]u8,   // preformatted display string

// RIGHT: the model keeps the sources; methods derive per rebuild
pub fn visibleCount(model: *const Model) usize { ... }
pub fn visibleCents(model: *const Model) u64 { ... }
```

Derived numbers need no allocation — bind the methods and let text interpolation compose the line:

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

## How bindings resolve

A path like `{h.streak}` resolves left to right, starting from the model or a `for` variable:

- Struct fields bind directly: `{habit_count}`, `{h.done}`.
- Zero-argument pub methods bind like fields: `{totalDays}` calls `pub fn totalDays(m: *const Model) usize`.
- Arena-taking scalar methods bind the same way: `{summary}` calls `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8` — format derived display strings straight into the build arena, which lives exactly one view build. Works anywhere a scalar binding does, except inside `{a == b}` equality (compare the source fields, or bind a `bool`-returning method).
- Enums resolve to their tag name, so `{f}` renders `"active"`, `{f == filter}` compares tags, and `set_filter:{f}` coerces the tag back into an enum payload.
- `for each="name"` resolves, in order: a model field that is a slice or array, a pub array/slice declaration, a pub fn taking `(*const Model)`, or a pub fn taking `(*const Model, std.mem.Allocator)` — the allocator variant is how filtered and derived lists work.
- Item methods work too: `{h.name}` may be a field or `pub fn name(h: *const Habit) []const u8`.

Bindings are zero-argument by design. A parameterized query (the cards of column X) becomes one model function per case, or a [template](/docs/native-ui#templates) arg.

## Format at view time, in the arena

Computed strings — money, dates, percentages — are formatted into the build arena inside a derived-list function. Store amounts as integer cents; format when the view asks:

```zig
pub const VisibleExpense = struct { id: u32, date: []const u8, amount: []const u8 };

pub fn visible(model: *const Model, arena: std.mem.Allocator) []const VisibleExpense {
    const out = arena.alloc(VisibleExpense, model.expense_count) catch return &.{};
    var count: usize = 0;
    for (model.expenses[0..model.expense_count]) |*e| {
        if (!model.matches(e.*)) continue;
        out[count] = .{
            .id = e.id,
            .date = e.date(),
            .amount = std.fmt.allocPrint(arena, "${d}.{d:0>2}", .{ e.amount_cents / 100, e.amount_cents % 100 }) catch "",
        };
        count += 1;
    }
    return out[0..count];
}
```

A one-off formatted line is an arena-taking scalar function bound directly:

```zig
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 "";
}
```

```html
<status-bar>{summary}</status-bar>
```

The arena lives for exactly one view build, so nothing is stored and nothing goes stale. (These formatting samples are Zig: view-time formatting runs in the view tier, which a zero-config TypeScript app never writes. Inside a TypeScript core, derived display text is bytes built with `asciiBytes` templates — see [TypeScript Cores](/docs/typescript#the-subset-posture).)

For `<if test>`, prefer an explicit boolean predicate method over numeric truthiness: `test="{hasHabits}"` with `pub fn hasHabits(m: *const Model) bool` states the condition; `test="{habit_count}"` works (non-zero is truthy) but hides it.

## Text fields: the mirror pattern

The model applies every edit event and is the source of truth; the runtime keeps caret and selection while your source text matches, and a source-side change (like clearing on submit) wins:

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

```ts
// model field: the bytes the field renders; markup binds {draft}
//   readonly draft: Uint8Array;
// in update (msg.edit is the TextInputEvent mirror union your core declares):
case "draft_edit":
  return { ...model, draft: applyEdit(model.draft, msg.edit) }; // mirror every edit
case "add":
  return { ...withNewTask(model), draft: new Uint8Array(0) };   // clearing the source clears the field
```

```zig
draft_buffer: canvas.TextBuffer(64) = .{},            // model field: text + selection + composition
pub fn draft(model: *const Model) []const u8 {        // the fn the markup's text= binds
    return model.draft_buffer.text();
}
// in update:
.draft_edit => |edit| model.draft_buffer.apply(edit), // mirror every edit
.add => { model.addTask(model.draft_buffer.text()); model.draft_buffer.clear(); },  // clearing the source clears the field
```

`on-input` names a `Msg` variant carrying the text-input event. In a TypeScript core, import `TextInputEvent` and `applyTextInputEvent` from `@native-sdk/core/text` to apply edits to draft bytes. In a Zig core the payload is `canvas.TextInputEvent` and `canvas.TextBuffer` applies each event and holds the text — the markup binds the function (`text="{draft}"`), never the buffer: binding a `TextBuffer` model field directly is a teaching error pointing at this pattern. The runtime also handles clipboard and selection: the runtime owns cmd/ctrl+C/X/V in editable text, cut and paste arrive as ordinary edit events, and those edits use the same reducer.

## Echo runtime-applied values back

Some state is applied by the runtime first — a scroll offset, a split-pane fraction — and delivered to `update` after the fact. The rule is the same everywhere: **the Msg carries the value the runtime already applied; store it in the model and echo it back through the element's `value`.** The echoed source value equals the runtime's, so the reconcile treats it as unchanged and rebuilds never fight live interaction.

```html
<split value="{sidebar_split}" on-resize="sidebar_resized">
  <column min-width="150">…</column>
  <column min-width="280">…</column>
</split>
```

```ts
case "sidebar_resized":
  return { ...model, sidebarSplit: msg.fraction };
```

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

`on-scroll` uses `ScrollState` from `@native-sdk/core/events` in TypeScript or `canvas.ScrollState` in Zig. Store `offsetY` (Zig: `offset_y`) and bind it to `value`; use `offsetX` (Zig: `offset_x`) with `value-x` for horizontal scrolling. See [Native UI: Messages](/docs/native-ui#messages).

## Time: a testable seam

In a TypeScript core this is settled by rule: `update` is deterministic, and time arrives as message payloads (`Cmd.now`, `Cmd.delay`, `Sub.timer` — see [TypeScript Cores](/docs/typescript#effects-are-cmd-data)). The seam below is the Zig core's equivalent. Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees — do not call `clock_gettime` yourself. The facade owns the clocks:

```zig
native_sdk.nowMs()                  // wall ms since the Unix epoch (i64) — timestamps
native_sdk.nowNanoseconds()         // wall ns (i128)
native_sdk.monotonicMs()            // duration clock (u64, never goes backwards)
native_sdk.monotonicNanoseconds()   // subtract two reads for an elapsed time
```

Time-dependent logic should hold the seam in the model instead of calling the free functions, so tests stay deterministic:

```zig
pub const Model = struct { clock: native_sdk.Clock = .system, ... };
// in update:
.step_started => model.entry.started_ms = model.clock.wallMs(),

// in tests:
var test_clock: native_sdk.TestClock = .{};
model.clock = test_clock.clock();
test_clock.advanceMs(1500);
```

Wall time answers "what time is it?" (it jumps with OS clock adjustments); monotonic time answers "how long did it take?". Don't subtract wall timestamps for durations.

## Async state

Anything that leaves the process — subprocesses, HTTP, files, timers — flows through the effects channel and back into `update` as ordinary messages, so the model remains the single source of truth even for in-flight work. Keep the in-flight marker (`loading: bool`, an effect key) in the model like any other state. See [Native UI: Effects](/docs/native-ui#effects).

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)