---
title: "상태와 이벤트 — zenit Zig UI 문서"
description: "프레임을 넘어 유지되어야 하는 상태는 Cx가 소유하게 하고, 사용자 입력에는 타입 안전한 handler로 응답합니다."
url: https://zenit.z.express/ko/docs/guide/state-events
language: ko
alternate_en: https://zenit.z.express/docs/guide/state-events.md
alternate_zh: https://zenit.z.express/zh/docs/guide/state-events.md
alternate_es: https://zenit.z.express/es/docs/guide/state-events.md
alternate_ja: https://zenit.z.express/ja/docs/guide/state-events.md
alternate_fr: https://zenit.z.express/fr/docs/guide/state-events.md
alternate_de: https://zenit.z.express/de/docs/guide/state-events.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 상태와 이벤트

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

## 바인딩된 상태

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

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

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

> NOTE
> 
> **익명인가, 주소 지정 가능인가?** `bindState`는 호출할 때마다 새 항목을 만들며, ID는 호출 순서대로 익명으로 할당되어 이번 마운트 안에서만 의미가 있습니다. 다른 곳이나 다음 마운트에서 같은 상태를 ID로 가져와야 한다면 `cx.state(T, id, init)`를 쓰고 명시적 ID를 직접 정하십시오.

## 컴포넌트에 연결

컴포넌트의 콜백 필드는 모두 `?HandlerRef`입니다. 앞에서 얻은 `on_click`을 [Button](https://zenit.z.express/ko/components/button)에 넘기고, 갱신할 텍스트 노드를 상태 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);
```

값을 전달받는 콜백([Checkbox](https://zenit.z.express/ko/components/checkbox)나 Switch의 선택 상태, Input이나 Tabs의 텍스트 또는 id)에는 `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://zenit.z.express/media/stories/checkbox.mp4?v=079ee6a961)

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) void` | Checkbox, Switch, Accordion |
| `ui.Cx.strHandlerFrom(…)` | `fn (*State, []const u8) void` | Input, 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` |
| 원시 키/마우스, 스크롤, IME | `ui.events.Event` |

## 수명 주기와 정리

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

OWNERSHIP

두 가지 수명 주기: Cx 수준(창 / 앱 전체)과 Scope 수준(페이지나 패널 하나).

`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
> 
> **deinit을 두 번 하지 마십시오.** 이전 문서는 바인딩된 상태에 `scope.onCleanup(T, state, T.deinit)`도 등록하라고 안내했습니다. `deinit`이 pub이면 프레임워크가 이미 호출하므로, 다시 등록하면 모든 것이 두 번 해제됩니다.

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

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

-   **초깃값은 리소스를 소유하지 않습니다.** `bindState`에는 제로 값이나 포인터, allocator 같은 빌린 필드만 넘기고, 할당이 필요한 것은 `*T`를 얻은 뒤에 만듭니다.
    
-   **allocator를 필드로 보관합니다.** `deinit(*T)`는 인자가 하나뿐이므로 해제에 쓸 allocator는 struct 안에 있어야 합니다.
    
-   **죽은 노드를 붙잡지 마십시오.** Node 포인터는 소속 트리와 Scope가 살아 있는 동안에만 유효합니다. 페이지가 언마운트되거나 재구성된 뒤에는 상태를 통해 옛 노드에 접근하지 마십시오.
