docs/guide/state-events
핵심 개념 · Interaction

상태와 이벤트

프레임을 넘어 유지되어야 하는 상태는 Cx가 소유하게 하고, 사용자 입력에는 타입 안전한 handler로 응답합니다.

약 9분 소요

바인딩된 상태

cx.bindState(T, init)는 Cx 상태 저장소에 T를 만들고 프레임이 바뀌어도 유효한 *T를 반환합니다. 이어서 cx.on이 그 메서드를 어떤 컴포넌트든 받을 수 있는 HandlerRef로 바꿉니다. 상태 ID를 직접 할당할 필요가 없습니다.

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);

콜백에서 텍스트를 바꿀 때는 node.setTextContent(allocator, text)를 사용합니다. 내용을 복사해 owned로 표시하고, 이전의 owned 내용을 해제하며, 텍스트가 실제로 바뀌었을 때만 노드를 dirty로 표시합니다. 수동 markRenderDirty는 필요 없습니다. 그래서 struct에 allocator 필드를 둡니다.

컴포넌트에 연결

컴포넌트의 콜백 필드는 모두 ?HandlerRef입니다. 앞에서 얻은 on_click을 Button에 넘기고, 갱신할 텍스트 노드를 상태 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);

값을 전달받는 콜백(Checkbox나 Switch의 선택 상태, Input이나 Tabs의 텍스트 또는 id)에는 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);
harness가 Checkbox의 실제 히트 영역을 클릭합니다. 선택 표시, on_change 콜백, 상태 텍스트가 같은 프레임 파이프라인에서 갱신됩니다.

클릭 한 번의 경로

플랫폼이 보내는 것은 누름과 뗌이지 ‘클릭’이 아닙니다. 히트 테스트는 Z 순서로 앞에서부터 자식을 훑어 최상위 노드를 찾습니다. 누름과 뗌이 같은 요소에서 일어나면 EventDispatcher가 click을 합성해 capture → target → bubble 세 단계로 전달하고, 노드의 on_click HandlerRef를 호출해 최종적으로 바인딩한 메서드에 도달합니다.

EVENT ROUTING
HandlerRef는 ‘콜백 함수 + 컨텍스트 포인터’일 뿐입니다. cx.on이 만든 callback은 컨텍스트를 다시 *Counter로 변환해 메서드를 호출하며, 상태 자체는 계속 Cx 상태 저장소에 남아 있습니다.
EventResult의미
.ignored처리하지 않음. 전파를 계속하고 기본 동작을 허용
.handled처리함. 전파는 계속하되 기본 동작을 막음
.stop처리함. 전파를 중단

Handler 만들기

생성자메서드 시그니처용도
cx.on(State, s, State.m)fn (*State) void버튼 클릭 같은 ‘일어났다’ 이벤트
ui.Cx.boolHandlerFrom(…)fn (*State, bool) voidCheckbox, Switch, Accordion
ui.Cx.strHandlerFrom(…)fn (*State, []const u8) voidInput, Textarea, Tabs, Radio. 슬라이스는 호출 중에만 유효
ui.clickable(node, handler)—아무 노드에나 on_click 연결

저수준 이벤트

버튼, 입력 필드 같은 컴포넌트는 흔한 상호작용을 이미 감싸고 있습니다. ui.events.Event는 커스텀 상호작용을 구현할 때만 필요합니다. 마우스(누름, 뗌, click, double_click, 이동, 진입/이탈, 스크롤, 확대/축소, 파일 드래그), 키보드, text_input, ime_preedit / ime_commit, 포커스를 다룹니다. 키보드 명령은 ui.actions, 포커스 이동은 ui.focus를 우선 사용하십시오.

필요권장 API
클릭, 입력, 선택ui.widgets.* callbacks
호버, 포커스 링ui.hooks.useHover / useFocusRing
단축키와 명령ui.actions
드래그, 범위 호버ui.interaction.drag / range_hover
원시 키/마우스, 스크롤, IMEui.events.Event

수명 주기와 정리

바인딩된 상태는 Cx가 소유하며 Cx가 해제될 때 함께 해제됩니다. T가 pub deinit(*T)를 선언하면 프레임워크가 해제 직전에 자동으로 호출합니다. ArrayList, HashMap, 할당한 버퍼의 정리는 여기에 두면 됩니다. Signal, Memo, Effect, onCleanup 콜백은 Scope에 속하며 scope.dispose()와 함께 해제됩니다.

OWNERSHIP
두 가지 수명 주기: Cx 수준(창 / 앱 전체)과 Scope 수준(페이지나 패널 하나).
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.

scope.onCleanup은 두 경우에만 사용합니다. 정리 함수가 pub deinit이 아닐 때, 또는 리소스가 Cx 전체가 아니라 특정 Scope(페이지)를 따라 해제되어야 할 때입니다.

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);
  • ✓

    초깃값은 리소스를 소유하지 않습니다. bindState에는 제로 값이나 포인터, allocator 같은 빌린 필드만 넘기고, 할당이 필요한 것은 *T를 얻은 뒤에 만듭니다.

  • ✓

    allocator를 필드로 보관합니다. deinit(*T)는 인자가 하나뿐이므로 해제에 쓸 allocator는 struct 안에 있어야 합니다.

  • ✓

    죽은 노드를 붙잡지 마십시오. Node 포인터는 소속 트리와 Scope가 살아 있는 동안에만 유효합니다. 페이지가 언마운트되거나 재구성된 뒤에는 상태를 통해 옛 노드에 접근하지 마십시오.

zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30