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.
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.
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.
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);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.
.ignoredNot handled; keep propagating and allow the default behavior.handledHandled; keep propagating but prevent the default.stopHandled; stop propagationBuilding handlers
cx.on(State, s, State.m)fn (*State) voidButton clicks and other “it happened” eventsui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox, Switch, Accordionui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput, Textarea, Tabs, Radio; the slice is only valid during the callui.clickable(node, handler)—Attach on_click to any nodeLow-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.
ui.widgets.* callbacksui.hooks.useHover / useFocusRingui.actionsui.interaction.drag / range_hoverui.events.EventLifetime & 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().
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.
// 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.