docs/guide/state-events
Core concepts · Interaction

State & events

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

9 min read

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
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.

Wiring components

Component callback fields are all ?HandlerRef. Hand the on_click from above to a Button, and store the text node you want to update in the state struct.

view.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 or Switch, the text or id from Input or Tabs) use a value-carrying constructor such as ui.Cx.boolHandlerFrom.

prefs.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);
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.
EventResultMeaning
.ignoredNot handled; keep propagating and allow the default behavior
.handledHandled; keep propagating but prevent the default
.stopHandled; stop propagation

Building handlers

ConstructorMethod signatureUse for
cx.on(State, s, State.m)fn (*State) voidButton clicks and other “it happened” events
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox, Switch, Accordion
ui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput, 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.

NeedPreferred API
Click, type, selectui.widgets.* callbacks
Hover, focus ringui.hooks.useHover / useFocusRing
Shortcuts and commandsui.actions
Drag, range hoverui.interaction.drag / range_hover
Raw keys/mouse, scroll, IMEui.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
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.

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
// 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.

zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01