docs/guide/reactivity
コアコンセプト · Data flow

リアクティビティ

事実は Signal に保持し、派生値は Memo で表し、外部との同期は Effect で行います。

約 10 分で読めます

3 つのプリミティブ

01
Signal

書き込み可能な事実の源です。get() で読み、set(v) で書きます。読み取ると現在の計算に依存が登録されます。

02
Memo

他のリアクティブ値から遅延計算されます。入力が変わらなければ前回の結果を再利用し、値が等しければ下流に通知しません。

03
Effect

依存が変わったときに副作用を実行します。ノードの更新、ログ出力、外部システムとの同期などです。

エンドツーエンドのデータフロー

ui.textFmt は渡された Signal / Memo を購読し、それらが変わるとその 1 つのテキストノードだけを再整形します。Memo の依存は明示的なコンテキスト構造体で渡します。

counter.zig
const count = try scope.createSignal(u32, 0);

const doubled = try scope.createMemo(u32, .{ .count = count }, struct {
    fn compute(ctx: anytype) u32 {
        return ctx.count.get() * 2;
    }
}.compute);

try root.appendChild(cx.allocator, try ui.textFmt(
    cx,
    scope,
    "count = {d}, doubled = {d}",
    .{ count, doubled },
    .{ .color = cx.tokens.color.fg_primary },
));
DATA FLOW
1 回の set が依存の辺を伝わり(Signal → Memo → textFmt)、render dirty になるのは購読しているテキストノードだけです。

diff ではなく Signal

zenit はレイアウト・ヒットテスト・描画のために実際の UI ツリーを保持しますが、更新は「コンポーネントを再実行 → 候補ツリーを生成 → ツリー全体を diff」という経路を通りません。Signal を読む Memo / Effect は依存を登録し、set はそれらの購読者だけに通知して、影響を受けるノードを layout または render dirty にします。

SIGNAL vs DIFF
ツリーは残ります。なくなるのは、変更のたびに候補ツリーを作り直して差分を探す工程です。
実際の Reactive Counter.app:+1 のたびに count と doubled が同時に 3 / 6 まで更新され、Reset で両方の購読結果が 0 に戻ります。

イベントから Signal を更新

コンポーネントのコールバックは HandlerRef です。cx.bindState で Signal のポインタを持つ小さな構造体を保持し、cx.on でそのメソッドをコールバックに変えます。

handlers.zig
const Bindings = struct {
    count: *ui.Signal(u32),

    fn increment(self: *Bindings) void {
        self.count.set(self.count.get() + 1);
    }
};

const bindings = try cx.bindState(Bindings, .{ .count = count });
const on_click = cx.on(Bindings, bindings, Bindings.increment);

const plus = try ui.widgets.Button(.{
    .label = "+1",
    .on_click = on_click,
}).mount(scope, cx);

読み取りと購読

API役割
signal.get()読み取り、現在の Memo / Effect に依存を登録
signal.peek()購読せずに読み取る。ハンドラ内でのスナップショットに便利
signal.set(v)書き込み、購読者に通知
signal.update(fn)前の値から次の値を計算して書き込む
scope.createEffect(ctx, fn)変更時に副作用を実行。Scope とともに解放
effects.zig
// Effect: sync a value to something outside the reactive graph.
try scope.createEffect(.{ .count = count }, struct {
    fn run(ctx: anytype) void {
        std.log.info("count is now {d}", .{ctx.count.get()});
    }
}.run);

// peek(): read without subscribing (no dependency is recorded).
const snapshot = count.peek();

// update(): read-modify-write in one call.
count.update(struct {
    fn inc(v: u32) u32 {
        return v + 1;
    }
}.inc);

使い分けの目安

  • ✓

    1 つの事実に 1 つの Signal。同じ状態を通常のフィールドと Signal の両方に持たないでください。

  • ✓

    計算できる値は Memo に。派生フィールドを各ハンドラで手作業で同期しないでください。

  • ✓

    Effect は同期だけ。Effect 自身の依存に無条件で書き戻さないでください。ループになります。

  • ✓

    寿命は Scope に属します。ページの Scope を破棄すると、その Signal・Memo・Effect も一緒に解放されます。

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