---
title: "State & events | zenit Zig UI Docs"
description: "Let the Cx own state that must survive across frames, and answer user input with type-safe handlers."
url: https://ui.surf/zenit/docs/guide/state-events
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/state-events.md
alternate_es: https://ui.surf/zenit/es/docs/guide/state-events.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/state-events.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/state-events.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/state-events.md
alternate_de: https://ui.surf/zenit/de/docs/guide/state-events.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# State & events

Let the Cx own state that must survive across frames, and answer user input with type-safe handlers.

## Bound state

`cx.bindState(T, init)` creates a `T` in the Cx state store and returns a `*T` that stays valid across frames. `cx.on` then turns one of its methods into a `HandlerRef` any component accepts, with no hand-assigned state IDs.

`counter.zig`

```zig
const Counter = struct {
    allocator: std.mem.Allocator,
    value: u32 = 0,
    label: ?*ui.Node = null,
    buffer: [32]u8 = undefined,

    fn increment(self: *Counter) void {
        self.value += 1;
        const label = self.label orelse return;
        const text = std.fmt.bufPrint(
            &self.buffer,
            "Clicked {d} times",
            .{self.value},
        ) catch return;
        // Copies the text, marks it owned, frees the previous owned copy,
        // and marks the node dirty only if the text actually changed.
        label.setTextContent(self.allocator, text) catch return;
    }
};

const counter = try cx.bindState(Counter, .{ .allocator = cx.allocator });
const on_click = cx.on(Counter, counter, Counter.increment);
```

Swap text from a callback with `node.setTextContent(allocator, text)`: it copies the content and marks it owned, frees the previous owned copy, and only marks the node dirty when the text actually changed. No manual `markRenderDirty` is needed. That’s why the struct keeps an `allocator` field.

> NOTE
> 
> **Anonymous or addressable?** `bindState` creates a fresh entry on every call, with an anonymous ID allocated in call order that only means something within this mount. If you need to fetch the same state by ID elsewhere or on a later mount, use `cx.state(T, id, init)` with an explicit ID you choose.

## Wiring components

Component callback fields are all `?HandlerRef`. Hand the `on_click` from above to a [Button](https://ui.surf/zenit/components/button), and store the text node you want to update in the state struct.

`view.zig`

```zig
const label = try ui.text(cx, "Clicked 0 times", .{
    .color = cx.tokens.color.fg_primary,
});
counter.label = label;

const button = try ui.widgets.Button(.{
    .label = "Increment",
    .variant = .primary,
    .on_click = on_click,
}).mount(scope, cx);

try root.appendChild(cx.allocator, button);
try root.appendChild(cx.allocator, label);
```

Callbacks that carry a value (the checked state of a [Checkbox](https://ui.surf/zenit/components/checkbox) or Switch, the text or id from Input or Tabs) use a value-carrying constructor such as `ui.Cx.boolHandlerFrom`.

`prefs.zig`

```zig
const Prefs = struct {
    notify: bool = false,

    fn setNotify(self: *Prefs, checked: bool) void {
        self.notify = checked;
    }
};

const prefs = try cx.bindState(Prefs, .{});

const notify = try ui.widgets.Checkbox(.{
    .label_text = "Notify me",
    .on_change = ui.Cx.boolHandlerFrom(Prefs, prefs, Prefs.setNotify),
}).mount(scope, cx);
```

[Video](https://ui.surf/zenit/media/stories/checkbox.mp4?v=079ee6a961)

The harness clicks the Checkbox’s real hit area: the checked visual, the on\_change callback and the status text all update in the same frame pipeline.

## How a click travels

The platform delivers presses and releases, not “clicks”. Hit-testing walks children back to front (Z order) to find the topmost node; when down and up land on the same element, the `EventDispatcher` synthesizes a `click`, dispatches it through capture -> target -> bubble, calls the node’s `on_click` `HandlerRef`, and ends up in the method you bound.

EVENT ROUTING

A HandlerRef is just “callback + context pointer”. The callback built by cx.on casts the context back to \*Counter and calls the method; the state itself stays in the Cx state store.

| EventResult | Meaning |
| --- | --- |
| `.ignored` | Not handled; keep propagating and allow the default behavior |
| `.handled` | Handled; keep propagating but prevent the default |
| `.stop` | Handled; stop propagation |

## Building handlers

| Constructor | Method signature | Use for |
| --- | --- | --- |
| `cx.on(State, s, State.m)` | `fn (*State) void` | Button clicks and other “it happened” events |
| `ui.Cx.boolHandlerFrom(…)` | `fn (*State, bool) void` | Checkbox, Switch, Accordion |
| `ui.Cx.strHandlerFrom(…)` | `fn (*State, []const u8) void` | Input, Textarea, Tabs, Radio; the slice is only valid during the call |
| `ui.clickable(node, handler)` | — | Attach on\_click to any node |

## Low-level events

Buttons, inputs and friends already wrap the common interactions. Reach for `ui.events.Event` only for custom interaction: it covers the mouse (down, up, click, double\_click, move, enter/leave, scroll, magnify, file drag), keyboard, `text_input`, `ime_preedit` / `ime_commit`, and focus. Prefer `ui.actions` for keyboard commands and `ui.focus` for moving focus.

| Need | Preferred API |
| --- | --- |
| Click, type, select | `ui.widgets.* callbacks` |
| Hover, focus ring | `ui.hooks.useHover / useFocusRing` |
| Shortcuts and commands | `ui.actions` |
| Drag, range hover | `ui.interaction.drag / range_hover` |
| Raw keys/mouse, scroll, IME | `ui.events.Event` |

## Lifetime & cleanup

Bound state is owned by the **Cx** and freed when the Cx deinits. If `T` declares a **pub** `deinit(*T)`, the framework calls it right before freeing. Put your ArrayList, HashMap and buffer cleanup there. Signals, Memos, Effects and `onCleanup` callbacks belong to a **Scope** and go away with `scope.dispose()`.

OWNERSHIP

Two lifetimes: Cx-wide (the whole window / app) and Scope-wide (one page or panel).

`editor_state.zig`

```zig
const EditorState = struct {
    allocator: std.mem.Allocator,
    lines: std.ArrayList([]u8) = .{},

    // Must be pub: the state store detects it and calls it when the Cx deinits.
    pub fn deinit(self: *EditorState) void {
        for (self.lines.items) |line| self.allocator.free(line);
        self.lines.deinit(self.allocator);
    }
};

// The initial value must not own resources yet; allocate after binding.
const editor = try cx.bindState(EditorState, .{ .allocator = cx.allocator });

// Do NOT also call scope.onCleanup(EditorState, editor, EditorState.deinit):
// the framework already calls deinit, so that would free everything twice.
```

> WARNING
> 
> **Don’t deinit twice.** Older docs suggested also registering `scope.onCleanup(T, state, T.deinit)` for bound state. If `deinit` is pub, the framework already calls it; registering it again frees everything twice.

Use `scope.onCleanup` only when the cleanup is not a pub `deinit`, or when a resource must follow a Scope (a page) rather than the whole Cx.

`page_cache.zig`

```zig
// A resource that must follow the page (Scope), not the whole Cx.
const PageCache = struct {
    allocator: std.mem.Allocator,
    hits: std.StringHashMapUnmanaged(u32) = .{},

    // Not named deinit, so the state store will not call it again.
    fn release(self: *PageCache) void {
        self.hits.deinit(self.allocator);
        self.hits = .{};
    }
};

const cache = try cx.bindState(PageCache, .{ .allocator = cx.allocator });
try scope.onCleanup(PageCache, cache, PageCache.release);
```

-   **Initial values own nothing.** Pass a zero value or only borrowed fields (pointers, an allocator) to `bindState`; allocate after you have the `*T`.
    
-   **Keep the allocator as a field.** `deinit(*T)` takes one argument, so the allocator used for freeing has to live in the struct.
    
-   **Don’t hold dead nodes.** A Node pointer is valid only while its tree and Scope live; after a page unmounts or rebuilds, don’t reach old nodes through state.
