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 の 3 段階で配送してノードの 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
2 種類のライフサイクル:Cx レベル(ウィンドウ / アプリ全体)と Scope レベル(1 つのページやパネル)。
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 を使うのは 2 つの場合だけです。クリーンアップ関数が 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) の引数は 1 つだけなので、解放に使う allocator は struct 内に保存しておく必要があります。

  • ✓

    無効になったノードをキャッシュしない。Node ポインタは所属するツリーと Scope が生きている間だけ有効です。ページのアンマウントや再構築の後は、状態経由で古いノードにアクセスしないでください。

zenit · デュアルライセンスオープンソースプロジェクトは GPL-3.0-only のもとで無料で使えます。クローズドソースや商用製品には商用ライセンスが必要です。作者への連絡先:zongyi.xzy#gmail.com(# を @ に置き換え)zenit 5f9add5+wip 2026-09-30